toke generation from profile and enhanced cost item to support hsn
This commit is contained in:
parent
60c5d4b475
commit
ff20bb7ef8
@ -52,6 +52,8 @@ scrape_configs:
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 10s
|
||||
scrape_timeout: 5s
|
||||
authorization:
|
||||
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
|
||||
|
||||
# ============================================
|
||||
# Node Exporter - Host Metrics
|
||||
|
||||
79
src/controllers/apiToken.controller.ts
Normal file
79
src/controllers/apiToken.controller.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiTokenService } from '../services/apiToken.service';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import { AuthenticatedRequest } from '../types/express';
|
||||
import { z } from 'zod';
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresInDays: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export class ApiTokenController {
|
||||
private apiTokenService: ApiTokenService;
|
||||
|
||||
constructor() {
|
||||
this.apiTokenService = new ApiTokenService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API Token
|
||||
*/
|
||||
async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const validation = createTokenSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
ResponseHandler.error(res, 'Validation error', 400, validation.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, expiresInDays } = validation.data;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const result = await this.apiTokenService.createToken(userId, name, expiresInDays);
|
||||
|
||||
ResponseHandler.success(res, {
|
||||
token: result.token,
|
||||
apiToken: result.apiToken
|
||||
}, 'API Token created successfully. Please copy the token now, you will not be able to see it again.');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to create API token', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's API Tokens
|
||||
*/
|
||||
async list(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const tokens = await this.apiTokenService.listTokens(userId);
|
||||
ResponseHandler.success(res, { tokens }, 'API Tokens retrieved successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to list API tokens', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API Token
|
||||
*/
|
||||
async revoke(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { id } = req.params;
|
||||
|
||||
const success = await this.apiTokenService.revokeToken(userId, id);
|
||||
|
||||
if (success) {
|
||||
ResponseHandler.success(res, null, 'API Token revoked successfully');
|
||||
} else {
|
||||
ResponseHandler.notFound(res, 'Token not found or already revoked');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to revoke API token', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,9 @@ import jwt from 'jsonwebtoken';
|
||||
import { User } from '../models/User';
|
||||
import { ssoConfig } from '../config/sso';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import { ApiTokenService } from '../services/apiToken.service';
|
||||
|
||||
const apiTokenService = new ApiTokenService();
|
||||
|
||||
interface JwtPayload {
|
||||
userId: string;
|
||||
@ -23,6 +26,29 @@ export const authenticateToken = async (
|
||||
const authHeader = req.headers.authorization;
|
||||
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
// Check if it's an API Token (starts with re_)
|
||||
if (token && token.startsWith('re_')) {
|
||||
const user = await apiTokenService.verifyToken(token);
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
ResponseHandler.unauthorized(res, 'Invalid or expired API token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach user info to request object
|
||||
req.user = {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
employeeId: user.employeeId || null,
|
||||
role: user.role
|
||||
};
|
||||
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to cookie if available (requires cookie-parser middleware)
|
||||
|
||||
// Fallback to cookie if available (requires cookie-parser middleware)
|
||||
if (!token && req.cookies?.accessToken) {
|
||||
token = req.cookies.accessToken;
|
||||
|
||||
50
src/migrations/20260216-add-qty-hsn-to-expenses.ts
Normal file
50
src/migrations/20260216-add-qty-hsn-to-expenses.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Helper function to check if a column exists in a table
|
||||
*/
|
||||
async function columnExists(
|
||||
queryInterface: QueryInterface,
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const tableDescription = await queryInterface.describeTable(tableName);
|
||||
return columnName in tableDescription;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
|
||||
|
||||
const newColumns = {
|
||||
quantity: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1 },
|
||||
hsn_code: { type: DataTypes.STRING(20), allowNull: true }
|
||||
};
|
||||
|
||||
for (const table of tables) {
|
||||
for (const [colName, colSpec] of Object.entries(newColumns)) {
|
||||
if (!(await columnExists(queryInterface, table, colName))) {
|
||||
await queryInterface.addColumn(table, colName, colSpec);
|
||||
console.log(`Added column ${colName} to ${table}`);
|
||||
} else {
|
||||
console.log(`Column ${colName} already exists in ${table}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
|
||||
const columns = ['quantity', 'hsn_code'];
|
||||
|
||||
for (const table of tables) {
|
||||
for (const col of columns) {
|
||||
await queryInterface.removeColumn(table, col).catch((err) => {
|
||||
console.warn(`Failed to remove column ${col} from ${table}:`, err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/migrations/20260216-create-api-tokens.ts
Normal file
70
src/migrations/20260216-create-api-tokens.ts
Normal file
@ -0,0 +1,70 @@
|
||||
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('api_tokens', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
comment: 'User-friendly name for the token',
|
||||
},
|
||||
prefix: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
comment: 'First few characters of token for identification (e.g., re_1234)',
|
||||
},
|
||||
token_hash: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Bcrypt hash of the full token',
|
||||
},
|
||||
last_used_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
expires_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Optional expiration date',
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
allowNull: false,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
});
|
||||
|
||||
// Indexes
|
||||
await queryInterface.addIndex('api_tokens', ['user_id']);
|
||||
await queryInterface.addIndex('api_tokens', ['prefix']);
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('api_tokens');
|
||||
}
|
||||
105
src/models/ApiToken.ts
Normal file
105
src/models/ApiToken.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from './User';
|
||||
|
||||
interface ApiTokenAttributes {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
tokenHash: string;
|
||||
lastUsedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ApiTokenCreationAttributes extends Optional<ApiTokenAttributes, 'id' | 'lastUsedAt' | 'expiresAt' | 'isActive' | 'createdAt' | 'updatedAt'> { }
|
||||
|
||||
class ApiToken extends Model<ApiTokenAttributes, ApiTokenCreationAttributes> implements ApiTokenAttributes {
|
||||
public id!: string;
|
||||
public userId!: string;
|
||||
public name!: string;
|
||||
public prefix!: string;
|
||||
public tokenHash!: string;
|
||||
public lastUsedAt?: Date | null;
|
||||
public expiresAt?: Date | null;
|
||||
public isActive!: boolean;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
}
|
||||
|
||||
ApiToken.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'user_id',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id',
|
||||
},
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
},
|
||||
prefix: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
tokenHash: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
field: 'token_hash',
|
||||
},
|
||||
lastUsedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_used_at',
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'expires_at',
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
field: 'is_active',
|
||||
allowNull: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'ApiToken',
|
||||
tableName: 'api_tokens',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
}
|
||||
);
|
||||
|
||||
// Define associations
|
||||
ApiToken.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
User.hasMany(ApiToken, { foreignKey: 'userId', as: 'apiTokens' });
|
||||
|
||||
export { ApiToken };
|
||||
@ -11,6 +11,8 @@ interface DealerCompletionExpenseAttributes {
|
||||
amount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
quantity?: number;
|
||||
hsnCode?: string;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
@ -37,6 +39,8 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
|
||||
public amount!: number;
|
||||
public gstRate?: number;
|
||||
public gstAmt?: number;
|
||||
public quantity?: number;
|
||||
public hsnCode?: string;
|
||||
public cgstRate?: number;
|
||||
public cgstAmt?: number;
|
||||
public sgstRate?: number;
|
||||
@ -101,6 +105,17 @@ DealerCompletionExpense.init(
|
||||
allowNull: true,
|
||||
field: 'gst_amt'
|
||||
},
|
||||
quantity: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 1,
|
||||
field: 'quantity'
|
||||
},
|
||||
hsnCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'hsn_code'
|
||||
},
|
||||
cgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
|
||||
@ -11,6 +11,8 @@ interface DealerProposalCostItemAttributes {
|
||||
amount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
quantity?: number;
|
||||
hsnCode?: string;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
@ -37,6 +39,8 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
|
||||
public amount!: number;
|
||||
public gstRate?: number;
|
||||
public gstAmt?: number;
|
||||
public quantity?: number;
|
||||
public hsnCode?: string;
|
||||
public cgstRate?: number;
|
||||
public cgstAmt?: number;
|
||||
public sgstRate?: number;
|
||||
@ -102,6 +106,17 @@ DealerProposalCostItem.init(
|
||||
allowNull: true,
|
||||
field: 'gst_amt'
|
||||
},
|
||||
quantity: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 1,
|
||||
field: 'quantity'
|
||||
},
|
||||
hsnCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'hsn_code'
|
||||
},
|
||||
cgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
// ApiToken association is defined in ApiToken.ts to avoid circular dependency issues
|
||||
// but we can declare the mixin type here if needed.
|
||||
|
||||
|
||||
/**
|
||||
* User Role Enum
|
||||
|
||||
16
src/routes/apiToken.routes.ts
Normal file
16
src/routes/apiToken.routes.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiTokenController } from '../controllers/apiToken.controller';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||
|
||||
const router = Router();
|
||||
const apiTokenController = new ApiTokenController();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.post('/', asyncHandler(apiTokenController.create.bind(apiTokenController)));
|
||||
router.get('/', asyncHandler(apiTokenController.list.bind(apiTokenController)));
|
||||
router.delete('/:id', asyncHandler(apiTokenController.revoke.bind(apiTokenController)));
|
||||
|
||||
export default router;
|
||||
@ -17,6 +17,7 @@ import dealerClaimRoutes from './dealerClaim.routes';
|
||||
import templateRoutes from './template.routes';
|
||||
import dealerRoutes from './dealer.routes';
|
||||
import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||
import apiTokenRoutes from './apiToken.routes';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||
|
||||
@ -50,6 +51,7 @@ router.use('/dealer-claims', dealerClaimRoutes);
|
||||
router.use('/templates', templateRoutes);
|
||||
router.use('/dealers', dealerRoutes);
|
||||
router.use('/webhooks/dms', dmsWebhookRoutes);
|
||||
router.use('/api-tokens', apiTokenRoutes);
|
||||
|
||||
// Add other route modules as they are implemented
|
||||
// router.use('/approvals', approvalRoutes);
|
||||
|
||||
@ -159,6 +159,8 @@ async function runMigrations(): Promise<void> {
|
||||
const m44 = require('../migrations/20260123-fix-template-id-schema');
|
||||
const m45 = require('../migrations/20260209-add-gst-and-pwc-fields');
|
||||
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
|
||||
const m47 = require('../migrations/20260216-create-api-tokens');
|
||||
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -210,6 +212,8 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20260123-fix-template-id-schema', module: m44 },
|
||||
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
|
||||
{ name: '20260210-add-raw-pwc-responses', module: m46 },
|
||||
{ name: '20260216-create-api-tokens', module: m47 },
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array
|
||||
];
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
|
||||
@ -48,6 +48,7 @@ import * as m42 from '../migrations/20250125-create-activity-types';
|
||||
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
|
||||
import * as m44 from '../migrations/20260123-fix-template-id-schema';
|
||||
import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields';
|
||||
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@ -109,7 +110,8 @@ const migrations: Migration[] = [
|
||||
{ name: '20250125-create-activity-types', module: m42 },
|
||||
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
|
||||
{ name: '20260123-fix-template-id-schema', module: m44 },
|
||||
{ name: '20260209-add-gst-and-pwc-fields', module: m45 }
|
||||
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
|
||||
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
74
src/scripts/test-api-tokens.ts
Normal file
74
src/scripts/test-api-tokens.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from '../models/User';
|
||||
import { ApiTokenService } from '../services/apiToken.service';
|
||||
|
||||
async function testApiTokens() {
|
||||
try {
|
||||
console.log('🔌 Connecting to database...');
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connected');
|
||||
|
||||
const apiTokenService = new ApiTokenService();
|
||||
|
||||
// 1. Find an admin user
|
||||
const adminUser = await User.findOne({ where: { role: 'ADMIN' } });
|
||||
if (!adminUser) {
|
||||
console.error('❌ No admin user found. Please seed the database first.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`👤 Found Admin User: ${adminUser.email}`);
|
||||
|
||||
// 2. Create a Token
|
||||
console.log('🔑 Creating API Token...');
|
||||
const tokenName = 'Test Token ' + Date.now();
|
||||
const { token, apiToken } = await apiTokenService.createToken(adminUser.userId, tokenName, 30);
|
||||
console.log(`✅ Token Created: ${token}`);
|
||||
console.log(` ID: ${apiToken.id}`);
|
||||
console.log(` Prefix: ${apiToken.prefix}`);
|
||||
|
||||
// 3. Verify Token
|
||||
console.log('🔍 Verifying Token...');
|
||||
const verifiedUser = await apiTokenService.verifyToken(token);
|
||||
if (verifiedUser && verifiedUser.userId === adminUser.userId) {
|
||||
console.log('✅ Token Verification Successful');
|
||||
} else {
|
||||
console.error('❌ Token Verification Failed');
|
||||
}
|
||||
|
||||
// 4. List Tokens
|
||||
console.log('📋 Listing Tokens...');
|
||||
const tokens = await apiTokenService.listTokens(adminUser.userId);
|
||||
console.log(`✅ Found ${tokens.length} tokens`);
|
||||
const createdToken = tokens.find(t => t.id === apiToken.id);
|
||||
if (createdToken) {
|
||||
console.log('✅ Created token found in list');
|
||||
} else {
|
||||
console.error('❌ Created token NOT found in list');
|
||||
}
|
||||
|
||||
// 5. Revoke Token
|
||||
console.log('🚫 Revoking Token...');
|
||||
const revoked = await apiTokenService.revokeToken(adminUser.userId, apiToken.id);
|
||||
if (revoked) {
|
||||
console.log('✅ Token Revoked Successfully');
|
||||
} else {
|
||||
console.error('❌ Token Revocation Failed');
|
||||
}
|
||||
|
||||
// 6. Verify Revocation
|
||||
console.log('🔍 Verifying Revoked Token...');
|
||||
const revokedUser = await apiTokenService.verifyToken(token);
|
||||
if (!revokedUser) {
|
||||
console.log('✅ Revoked Token Verification Successful (Access Denied)');
|
||||
} else {
|
||||
console.error('❌ Revoked Token Verification Failed (Access Granted)');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test Failed:', error);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
testApiTokens();
|
||||
119
src/services/apiToken.service.ts
Normal file
119
src/services/apiToken.service.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { ApiToken } from '../models/ApiToken';
|
||||
import { User } from '../models/User';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class ApiTokenService {
|
||||
private static readonly TOKEN_PREFIX = 're_';
|
||||
private static readonly TOKEN_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Generate a new API token for a user
|
||||
*/
|
||||
async createToken(userId: string, name: string, expiresInDays?: number): Promise<{ token: string; apiToken: ApiToken }> {
|
||||
try {
|
||||
// 1. Generate secure random token
|
||||
const randomBytes = crypto.randomBytes(ApiTokenService.TOKEN_LENGTH);
|
||||
const randomString = randomBytes.toString('hex');
|
||||
const token = `${ApiTokenService.TOKEN_PREFIX}${randomString}`;
|
||||
|
||||
// 2. Hash the token
|
||||
const tokenHash = await bcrypt.hash(token, 10);
|
||||
|
||||
// 3. Calculate expiry
|
||||
let expiresAt: Date | undefined;
|
||||
if (expiresInDays) {
|
||||
expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
const apiToken = await ApiToken.create({
|
||||
userId,
|
||||
name,
|
||||
prefix: token.substring(0, 8), // Store first 8 chars (prefix + 5 chars) for lookup
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
logger.info(`API Token created for user ${userId}: ${name}`);
|
||||
|
||||
// Return raw token ONLY here
|
||||
return { token, apiToken };
|
||||
} catch (error) {
|
||||
logger.error('Error creating API token:', error);
|
||||
throw new Error('Failed to create API token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active tokens for a user
|
||||
*/
|
||||
async listTokens(userId: string): Promise<ApiToken[]> {
|
||||
return await ApiToken.findAll({
|
||||
where: { userId, isActive: true },
|
||||
order: [['createdAt', 'DESC']],
|
||||
attributes: { exclude: ['tokenHash'] } // Never return hash
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token
|
||||
*/
|
||||
async revokeToken(userId: string, tokenId: string): Promise<boolean> {
|
||||
const result = await ApiToken.destroy({
|
||||
where: { id: tokenId, userId }
|
||||
});
|
||||
return result > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an API token and return the associated user
|
||||
*/
|
||||
async verifyToken(rawToken: string): Promise<User | null> {
|
||||
try {
|
||||
if (!rawToken.startsWith(ApiTokenService.TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Find potential tokens by prefix (optimization)
|
||||
const prefix = rawToken.substring(0, 8);
|
||||
const tokens = await ApiToken.findAll({
|
||||
where: {
|
||||
prefix,
|
||||
isActive: true
|
||||
},
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
where: { isActive: true } // Ensure user is also active
|
||||
}]
|
||||
});
|
||||
|
||||
// 2. check hash for each candidate
|
||||
for (const tokenRecord of tokens) {
|
||||
const isValid = await bcrypt.compare(rawToken, tokenRecord.tokenHash);
|
||||
if (isValid) {
|
||||
// 3. Check expiry
|
||||
if (tokenRecord.expiresAt && new Date() > tokenRecord.expiresAt) {
|
||||
logger.warn(`Expired API token used: ${tokenRecord.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Update last used
|
||||
await tokenRecord.update({ lastUsedAt: new Date() });
|
||||
|
||||
// Return the user (casted as any because include types can be tricky)
|
||||
return (tokenRecord as any).user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Error verifying API token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
||||
@ -24,7 +27,7 @@ import { activityService } from './activity.service';
|
||||
import { UserService } from './user.service';
|
||||
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||
import { findDealerLocally } from './dealer.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
|
||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||
|
||||
@ -171,7 +174,7 @@ export class DealerClaimService {
|
||||
levelNumber: a.level,
|
||||
levelName: levelName,
|
||||
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
||||
approverEmail: `system@${appDomain}`,
|
||||
approverEmail: a.email,
|
||||
approverName: a.name || a.email,
|
||||
tatHours: tatHours,
|
||||
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
|
||||
@ -1090,7 +1093,15 @@ export class DealerClaimService {
|
||||
// Use cost items from separate table
|
||||
costBreakup = proposalData.costItems.map((item: any) => ({
|
||||
description: item.itemDescription || item.description,
|
||||
amount: Number(item.amount) || 0
|
||||
amount: Number(item.amount) || 0,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
hsnCode: item.hsnCode || '',
|
||||
gstRate: Number(item.gstRate) || 0,
|
||||
gstAmt: Number(item.gstAmt) || 0,
|
||||
cgstAmt: Number(item.cgstAmt) || 0,
|
||||
sgstAmt: Number(item.sgstAmt) || 0,
|
||||
igstAmt: Number(item.igstAmt) || 0,
|
||||
totalAmt: Number(item.totalAmt) || 0
|
||||
}));
|
||||
}
|
||||
// Note: costBreakup JSONB field has been removed - only using separate table now
|
||||
@ -1156,7 +1167,14 @@ export class DealerClaimService {
|
||||
const expenseData = expense.toJSON ? expense.toJSON() : expense;
|
||||
return {
|
||||
description: expenseData.description || '',
|
||||
amount: Number(expenseData.amount) || 0
|
||||
amount: Number(expenseData.amount) || 0,
|
||||
gstRate: Number(expenseData.gstRate) || 0,
|
||||
gstAmt: Number(expenseData.gstAmt) || 0,
|
||||
cgstAmt: Number(expenseData.cgstAmt) || 0,
|
||||
sgstAmt: Number(expenseData.sgstAmt) || 0,
|
||||
igstAmt: Number(expenseData.igstAmt) || 0,
|
||||
totalAmt: Number(expenseData.totalAmt) || 0,
|
||||
expenseDate: expenseData.expenseDate
|
||||
};
|
||||
});
|
||||
|
||||
@ -1271,6 +1289,8 @@ export class DealerClaimService {
|
||||
requestId,
|
||||
itemDescription: item.description || item.itemDescription || '',
|
||||
amount: Number(item.amount) || 0,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
hsnCode: item.hsnCode || '',
|
||||
gstRate: Number(item.gstRate) || 0,
|
||||
gstAmt: Number(item.gstAmt) || 0,
|
||||
cgstRate: Number(item.cgstRate) || 0,
|
||||
@ -1423,6 +1443,8 @@ export class DealerClaimService {
|
||||
completionId,
|
||||
description: item.description,
|
||||
amount: Number(item.amount) || 0,
|
||||
quantity: Number(item.quantity) || 1,
|
||||
hsnCode: item.hsnCode || '',
|
||||
gstRate: Number(item.gstRate) || 0,
|
||||
gstAmt: Number(item.gstAmt) || 0,
|
||||
cgstRate: Number(item.cgstRate) || 0,
|
||||
@ -1914,7 +1936,15 @@ export class DealerClaimService {
|
||||
|| budgetTracking?.initialEstimatedBudget
|
||||
|| 0;
|
||||
|
||||
const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount);
|
||||
// Generate custom invoice number based on specific format: INDC + DealerCode + AB + Sequence
|
||||
// Format: INDC[DealerCode]AB[Sequence] (e.g., INDC004597AB0001)
|
||||
logger.info(`[DealerClaimService] Generating custom invoice number for dealer: ${claimDetails.dealerCode}`);
|
||||
const customInvoiceNumber = await this.generateCustomInvoiceNumber(claimDetails.dealerCode);
|
||||
logger.info(`[DealerClaimService] Generated custom invoice number: ${customInvoiceNumber} for request: ${requestId}`);
|
||||
|
||||
const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount, customInvoiceNumber);
|
||||
|
||||
logger.info(`[DealerClaimService] PWC Generation Result: Success=${invoiceResult.success}, AckNo=${invoiceResult.ackNo}, SignedInv present=${!!invoiceResult.signedInvoice}`);
|
||||
|
||||
if (!invoiceResult.success) {
|
||||
throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`);
|
||||
@ -1922,7 +1952,7 @@ export class DealerClaimService {
|
||||
|
||||
await ClaimInvoice.upsert({
|
||||
requestId,
|
||||
invoiceNumber: invoiceResult.ackNo, // Using Ack No as primary identifier for now
|
||||
invoiceNumber: customInvoiceNumber, // Use custom invoice number as primary identifier
|
||||
invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()),
|
||||
irn: invoiceResult.irn,
|
||||
ackNo: invoiceResult.ackNo,
|
||||
@ -2160,6 +2190,48 @@ export class DealerClaimService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genetate Custom Invoice Number
|
||||
* Format: INDC - DEALER CODE - AB (For FY) - sequence nos.
|
||||
* Sample: INDC004597AB0001
|
||||
*/
|
||||
private async generateCustomInvoiceNumber(dealerCode: string): Promise<string> {
|
||||
const fyCode = 'AB'; // Hardcoded FY code as per requirement
|
||||
// Ensure dealer code is padded/truncated if needed to fit length constraints, but requirement says "004597" which is 6 digits.
|
||||
// Assuming dealerCode is already correct length or we use it as is.
|
||||
const cleanDealerCode = (dealerCode || '000000').trim();
|
||||
|
||||
const prefix = `INDC${cleanDealerCode}${fyCode}`;
|
||||
|
||||
// Find last invoice with this prefix
|
||||
const lastInvoice = await ClaimInvoice.findOne({
|
||||
where: {
|
||||
invoiceNumber: {
|
||||
[Op.like]: `${prefix}%`
|
||||
}
|
||||
},
|
||||
order: [
|
||||
[sequelize.fn('LENGTH', sequelize.col('invoice_number')), 'DESC'],
|
||||
['invoice_number', 'DESC']
|
||||
]
|
||||
});
|
||||
|
||||
let sequence = 1;
|
||||
if (lastInvoice && lastInvoice.invoiceNumber) {
|
||||
// Extract the sequence part (last 4 digits)
|
||||
const lastSeqStr = lastInvoice.invoiceNumber.replace(prefix, '');
|
||||
const lastSeq = parseInt(lastSeqStr, 10);
|
||||
if (!isNaN(lastSeq)) {
|
||||
sequence = lastSeq + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Pad sequence to 4 digits
|
||||
const sequenceStr = sequence.toString().padStart(4, '0');
|
||||
|
||||
return `${prefix}${sequenceStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send credit note to dealer and auto-approve Step 8
|
||||
* This method sends the credit note to the dealer via email/notification and auto-approves Step 8
|
||||
|
||||
@ -119,13 +119,8 @@ export class PdfService {
|
||||
<div class="info-grid">
|
||||
<div class="info-section">
|
||||
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
|
||||
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">` + `{{BUYER_GSTIN}}` + `</div></div>
|
||||
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">33AAACE3882D1ZZ</div></div>
|
||||
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
|
||||
<br/>
|
||||
<div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div>
|
||||
<div class="info-row"><div class="info-label">Invoice No.</div><div class="info-value">${invoice.invoiceNumber || 'N/A'}</div></div>
|
||||
<div class="info-row"><div class="info-label">Invoice Date</div><div class="info-value">${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : 'N/A'}</div></div>
|
||||
<div class="info-row"><div class="info-label">Chassis No.</div><div class="info-value">N/A</div></div>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="info-row"><div class="info-label">Dealer</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>
|
||||
|
||||
@ -7,6 +7,8 @@ import { ClaimInvoice } from '../models/ClaimInvoice';
|
||||
import { InternalOrder } from '../models/InternalOrder';
|
||||
import { User } from '../models/User';
|
||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
|
||||
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
||||
|
||||
/**
|
||||
* PWC E-Invoice Integration Service
|
||||
@ -47,7 +49,7 @@ export class PWCIntegrationService {
|
||||
/**
|
||||
* Generate Signed Invoice via PWC API
|
||||
*/
|
||||
async generateSignedInvoice(requestId: string, amount?: number): Promise<{
|
||||
async generateSignedInvoice(requestId: string, amount?: number, customInvoiceNumber?: string): Promise<{
|
||||
success: boolean;
|
||||
irn?: string;
|
||||
ackNo?: string;
|
||||
@ -105,10 +107,59 @@ export class PWCIntegrationService {
|
||||
dealerStateCode = (dealer as any).stateCode;
|
||||
}
|
||||
|
||||
// Calculate tax amounts
|
||||
const gstRate = Number(activity.gstRate || 18);
|
||||
// Fetch expenses if available
|
||||
const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
|
||||
|
||||
let itemList: any[] = [];
|
||||
let totalAssAmt = 0;
|
||||
let totalIgstAmt = 0;
|
||||
let totalCgstAmt = 0;
|
||||
let totalSgstAmt = 0;
|
||||
let totalInvVal = 0;
|
||||
|
||||
const isIGST = dealerStateCode !== "33"; // If dealer state != Buyer state (33), it's IGST
|
||||
|
||||
if (expenses && expenses.length > 0) {
|
||||
itemList = expenses.map((expense: any, index: number) => {
|
||||
const qty = expense.quantity || 1;
|
||||
const rate = Number(expense.amount) || 0;
|
||||
const gstRate = Number(expense.gstRate || 18);
|
||||
|
||||
// Calculate per item tax
|
||||
const baseAmt = rate * qty;
|
||||
const igst = isIGST ? (baseAmt * (gstRate / 100)) : 0;
|
||||
const cgst = !isIGST ? (baseAmt * (gstRate / 200)) : 0;
|
||||
const sgst = !isIGST ? (baseAmt * (gstRate / 200)) : 0;
|
||||
const itemTotal = baseAmt + igst + cgst + sgst;
|
||||
|
||||
// Accumulate totals
|
||||
totalAssAmt += baseAmt;
|
||||
totalIgstAmt += igst;
|
||||
totalCgstAmt += cgst;
|
||||
totalSgstAmt += sgst;
|
||||
totalInvVal += itemTotal;
|
||||
|
||||
return {
|
||||
SlNo: String(index + 1),
|
||||
PrdNm: expense.description || activity.title,
|
||||
PrdDesc: expense.description || activity.title,
|
||||
HsnCd: expense.hsnCode || activity.hsnCode || activity.sacCode || "9983",
|
||||
IsServc: "Y",
|
||||
Qty: qty,
|
||||
Unit: "OTH",
|
||||
UnitPrice: formatAmount(rate),
|
||||
TotAmt: formatAmount(baseAmt),
|
||||
AssAmt: formatAmount(baseAmt),
|
||||
GstRt: gstRate,
|
||||
IgstAmt: formatAmount(igst),
|
||||
CgstAmt: formatAmount(cgst),
|
||||
SgstAmt: formatAmount(sgst),
|
||||
TotItemVal: formatAmount(itemTotal)
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Fallback to single line item if no expenses found
|
||||
const gstRate = Number(activity.gstRate || 18);
|
||||
const assAmt = finalAmount;
|
||||
const igstAmt = isIGST ? (finalAmount * (gstRate / 100)) : 0;
|
||||
const cgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
|
||||
@ -116,6 +167,31 @@ export class PWCIntegrationService {
|
||||
const totalTax = igstAmt + cgstAmt + sgstAmt;
|
||||
const totalItemVal = finalAmount + totalTax;
|
||||
|
||||
totalAssAmt = assAmt;
|
||||
totalIgstAmt = igstAmt;
|
||||
totalCgstAmt = cgstAmt;
|
||||
totalSgstAmt = sgstAmt;
|
||||
totalInvVal = totalItemVal;
|
||||
|
||||
itemList = [{
|
||||
SlNo: "1",
|
||||
PrdNm: activity.title,
|
||||
PrdDesc: activity.title,
|
||||
HsnCd: activity.hsnCode || activity.sacCode || "9983",
|
||||
IsServc: "Y",
|
||||
Qty: 1,
|
||||
Unit: "OTH",
|
||||
UnitPrice: formatAmount(finalAmount),
|
||||
TotAmt: formatAmount(finalAmount),
|
||||
AssAmt: formatAmount(assAmt),
|
||||
GstRt: gstRate,
|
||||
IgstAmt: formatAmount(igstAmt),
|
||||
CgstAmt: formatAmount(cgstAmt),
|
||||
SgstAmt: formatAmount(sgstAmt),
|
||||
TotItemVal: formatAmount(totalItemVal)
|
||||
}];
|
||||
}
|
||||
|
||||
// Construct PWC Payload - Aligned with sample format provided by user
|
||||
const payload = [
|
||||
{
|
||||
@ -140,7 +216,7 @@ export class PWCIntegrationService {
|
||||
},
|
||||
DocDtls: {
|
||||
Typ: "Inv",
|
||||
No: (request as any).requestNumber || `INV-${Date.now()}`,
|
||||
No: customInvoiceNumber || (request as any).requestNumber || `INV-${Date.now()}`,
|
||||
Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY
|
||||
},
|
||||
SellerDtls: {
|
||||
@ -157,7 +233,7 @@ export class PWCIntegrationService {
|
||||
Em: (dealer as any).dealerPrincipalEmailId || "Supplier@inv.com"
|
||||
},
|
||||
BuyerDtls: {
|
||||
Gstin: "{{BUYER_GSTIN}}", // Royal Enfield GST
|
||||
Gstin: "33AAACE3882D1ZZ", // Royal Enfield GST (Tamil Nadu)
|
||||
LglNm: "ROYAL ENFIELD (A UNIT OF EICHER MOTORS LTD)",
|
||||
TrdNm: "ROYAL ENFIELD",
|
||||
Addr1: "No. 2, Thiruvottiyur High Road",
|
||||
@ -166,31 +242,13 @@ export class PWCIntegrationService {
|
||||
Stcd: "33",
|
||||
Pos: "33"
|
||||
},
|
||||
ItemList: [
|
||||
{
|
||||
SlNo: "1",
|
||||
PrdNm: activity.title,
|
||||
PrdDesc: activity.title,
|
||||
HsnCd: activity.hsnCode || activity.sacCode || "9983",
|
||||
IsServc: "Y",
|
||||
Qty: 1,
|
||||
Unit: "OTH",
|
||||
UnitPrice: formatAmount(finalAmount), // Ensure number
|
||||
TotAmt: formatAmount(finalAmount), // Ensure number
|
||||
AssAmt: formatAmount(assAmt), // Ensure number
|
||||
GstRt: gstRate,
|
||||
IgstAmt: formatAmount(igstAmt),
|
||||
CgstAmt: formatAmount(cgstAmt),
|
||||
SgstAmt: formatAmount(sgstAmt),
|
||||
TotItemVal: formatAmount(totalItemVal)
|
||||
}
|
||||
],
|
||||
ItemList: itemList,
|
||||
ValDtls: {
|
||||
AssVal: formatAmount(assAmt),
|
||||
IgstVal: formatAmount(igstAmt),
|
||||
CgstVal: formatAmount(cgstAmt),
|
||||
SgstVal: formatAmount(sgstAmt),
|
||||
TotInvVal: formatAmount(totalItemVal)
|
||||
AssVal: formatAmount(totalAssAmt),
|
||||
IgstVal: formatAmount(totalIgstAmt),
|
||||
CgstVal: formatAmount(totalCgstAmt),
|
||||
SgstVal: formatAmount(totalSgstAmt),
|
||||
TotInvVal: formatAmount(totalInvVal)
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user