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
|
metrics_path: /metrics
|
||||||
scrape_interval: 10s
|
scrape_interval: 10s
|
||||||
scrape_timeout: 5s
|
scrape_timeout: 5s
|
||||||
|
authorization:
|
||||||
|
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Node Exporter - Host Metrics
|
# 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 { User } from '../models/User';
|
||||||
import { ssoConfig } from '../config/sso';
|
import { ssoConfig } from '../config/sso';
|
||||||
import { ResponseHandler } from '../utils/responseHandler';
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
|
import { ApiTokenService } from '../services/apiToken.service';
|
||||||
|
|
||||||
|
const apiTokenService = new ApiTokenService();
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -23,6 +26,29 @@ export const authenticateToken = async (
|
|||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
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)
|
// Fallback to cookie if available (requires cookie-parser middleware)
|
||||||
if (!token && req.cookies?.accessToken) {
|
if (!token && req.cookies?.accessToken) {
|
||||||
token = req.cookies.accessToken;
|
token = req.cookies.accessToken;
|
||||||
@ -35,10 +61,10 @@ export const authenticateToken = async (
|
|||||||
|
|
||||||
// Verify JWT token
|
// Verify JWT token
|
||||||
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
||||||
|
|
||||||
// Fetch user from database to ensure they still exist and are active
|
// Fetch user from database to ensure they still exist and are active
|
||||||
const user = await User.findByPk(decoded.userId);
|
const user = await User.findByPk(decoded.userId);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
ResponseHandler.unauthorized(res, 'User not found or inactive');
|
ResponseHandler.unauthorized(res, 'User not found or inactive');
|
||||||
return;
|
return;
|
||||||
@ -89,7 +115,7 @@ export const optionalAuth = async (
|
|||||||
if (token) {
|
if (token) {
|
||||||
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
||||||
const user = await User.findByPk(decoded.userId);
|
const user = await User.findByPk(decoded.userId);
|
||||||
|
|
||||||
if (user && user.isActive) {
|
if (user && user.isActive) {
|
||||||
req.user = {
|
req.user = {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@ -99,7 +125,7 @@ export const optionalAuth = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// For optional auth, we don't throw errors, just continue without user
|
// For optional auth, we don't throw errors, just continue without user
|
||||||
|
|||||||
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;
|
amount: number;
|
||||||
gstRate?: number;
|
gstRate?: number;
|
||||||
gstAmt?: number;
|
gstAmt?: number;
|
||||||
|
quantity?: number;
|
||||||
|
hsnCode?: string;
|
||||||
cgstRate?: number;
|
cgstRate?: number;
|
||||||
cgstAmt?: number;
|
cgstAmt?: number;
|
||||||
sgstRate?: number;
|
sgstRate?: number;
|
||||||
@ -37,6 +39,8 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
|
|||||||
public amount!: number;
|
public amount!: number;
|
||||||
public gstRate?: number;
|
public gstRate?: number;
|
||||||
public gstAmt?: number;
|
public gstAmt?: number;
|
||||||
|
public quantity?: number;
|
||||||
|
public hsnCode?: string;
|
||||||
public cgstRate?: number;
|
public cgstRate?: number;
|
||||||
public cgstAmt?: number;
|
public cgstAmt?: number;
|
||||||
public sgstRate?: number;
|
public sgstRate?: number;
|
||||||
@ -101,6 +105,17 @@ DealerCompletionExpense.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'gst_amt'
|
field: 'gst_amt'
|
||||||
},
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 1,
|
||||||
|
field: 'quantity'
|
||||||
|
},
|
||||||
|
hsnCode: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'hsn_code'
|
||||||
|
},
|
||||||
cgstRate: {
|
cgstRate: {
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
type: DataTypes.DECIMAL(5, 2),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -11,6 +11,8 @@ interface DealerProposalCostItemAttributes {
|
|||||||
amount: number;
|
amount: number;
|
||||||
gstRate?: number;
|
gstRate?: number;
|
||||||
gstAmt?: number;
|
gstAmt?: number;
|
||||||
|
quantity?: number;
|
||||||
|
hsnCode?: string;
|
||||||
cgstRate?: number;
|
cgstRate?: number;
|
||||||
cgstAmt?: number;
|
cgstAmt?: number;
|
||||||
sgstRate?: number;
|
sgstRate?: number;
|
||||||
@ -37,6 +39,8 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
|
|||||||
public amount!: number;
|
public amount!: number;
|
||||||
public gstRate?: number;
|
public gstRate?: number;
|
||||||
public gstAmt?: number;
|
public gstAmt?: number;
|
||||||
|
public quantity?: number;
|
||||||
|
public hsnCode?: string;
|
||||||
public cgstRate?: number;
|
public cgstRate?: number;
|
||||||
public cgstAmt?: number;
|
public cgstAmt?: number;
|
||||||
public sgstRate?: number;
|
public sgstRate?: number;
|
||||||
@ -102,6 +106,17 @@ DealerProposalCostItem.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'gst_amt'
|
field: 'gst_amt'
|
||||||
},
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 1,
|
||||||
|
field: 'quantity'
|
||||||
|
},
|
||||||
|
hsnCode: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'hsn_code'
|
||||||
|
},
|
||||||
cgstRate: {
|
cgstRate: {
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
type: DataTypes.DECIMAL(5, 2),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { DataTypes, Model, Optional } from 'sequelize';
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
import { sequelize } from '../config/database';
|
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
|
* User Role Enum
|
||||||
@ -21,7 +24,7 @@ interface UserAttributes {
|
|||||||
department?: string | null;
|
department?: string | null;
|
||||||
designation?: string | null;
|
designation?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
|
||||||
// Extended fields from SSO/Okta (All Optional)
|
// Extended fields from SSO/Okta (All Optional)
|
||||||
manager?: string | null; // Reporting manager name
|
manager?: string | null; // Reporting manager name
|
||||||
secondEmail?: string | null; // Alternate email
|
secondEmail?: string | null; // Alternate email
|
||||||
@ -30,7 +33,7 @@ interface UserAttributes {
|
|||||||
postalAddress?: string | null; // Work location/office address
|
postalAddress?: string | null; // Work location/office address
|
||||||
mobilePhone?: string | null; // Mobile contact (different from phone)
|
mobilePhone?: string | null; // Mobile contact (different from phone)
|
||||||
adGroups?: string[] | null; // Active Directory group memberships
|
adGroups?: string[] | null; // Active Directory group memberships
|
||||||
|
|
||||||
// Location Information (JSON object)
|
// Location Information (JSON object)
|
||||||
location?: {
|
location?: {
|
||||||
city?: string;
|
city?: string;
|
||||||
@ -39,12 +42,12 @@ interface UserAttributes {
|
|||||||
office?: string;
|
office?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notification Preferences
|
// Notification Preferences
|
||||||
emailNotificationsEnabled: boolean;
|
emailNotificationsEnabled: boolean;
|
||||||
pushNotificationsEnabled: boolean;
|
pushNotificationsEnabled: boolean;
|
||||||
inAppNotificationsEnabled: boolean;
|
inAppNotificationsEnabled: boolean;
|
||||||
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
@ -52,7 +55,7 @@ interface UserAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
|
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
|
||||||
|
|
||||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||||
public userId!: string;
|
public userId!: string;
|
||||||
@ -65,7 +68,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
public department?: string;
|
public department?: string;
|
||||||
public designation?: string;
|
public designation?: string;
|
||||||
public phone?: string;
|
public phone?: string;
|
||||||
|
|
||||||
// Extended fields from SSO/Okta (All Optional)
|
// Extended fields from SSO/Okta (All Optional)
|
||||||
public manager?: string | null;
|
public manager?: string | null;
|
||||||
public secondEmail?: string | null;
|
public secondEmail?: string | null;
|
||||||
@ -74,7 +77,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
public postalAddress?: string | null;
|
public postalAddress?: string | null;
|
||||||
public mobilePhone?: string | null;
|
public mobilePhone?: string | null;
|
||||||
public adGroups?: string[] | null;
|
public adGroups?: string[] | null;
|
||||||
|
|
||||||
// Location Information (JSON object)
|
// Location Information (JSON object)
|
||||||
public location?: {
|
public location?: {
|
||||||
city?: string;
|
city?: string;
|
||||||
@ -83,12 +86,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
office?: string;
|
office?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notification Preferences
|
// Notification Preferences
|
||||||
public emailNotificationsEnabled!: boolean;
|
public emailNotificationsEnabled!: boolean;
|
||||||
public pushNotificationsEnabled!: boolean;
|
public pushNotificationsEnabled!: boolean;
|
||||||
public inAppNotificationsEnabled!: boolean;
|
public inAppNotificationsEnabled!: boolean;
|
||||||
|
|
||||||
public isActive!: boolean;
|
public isActive!: boolean;
|
||||||
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||||
public lastLogin?: Date;
|
public lastLogin?: Date;
|
||||||
@ -96,26 +99,26 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper Methods for Role Checking
|
* Helper Methods for Role Checking
|
||||||
*/
|
*/
|
||||||
public isUserRole(): boolean {
|
public isUserRole(): boolean {
|
||||||
return this.role === 'USER';
|
return this.role === 'USER';
|
||||||
}
|
}
|
||||||
|
|
||||||
public isManagementRole(): boolean {
|
public isManagementRole(): boolean {
|
||||||
return this.role === 'MANAGEMENT';
|
return this.role === 'MANAGEMENT';
|
||||||
}
|
}
|
||||||
|
|
||||||
public isAdminRole(): boolean {
|
public isAdminRole(): boolean {
|
||||||
return this.role === 'ADMIN';
|
return this.role === 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasManagementAccess(): boolean {
|
public hasManagementAccess(): boolean {
|
||||||
return this.role === 'MANAGEMENT' || this.role === 'ADMIN';
|
return this.role === 'MANAGEMENT' || this.role === 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasAdminAccess(): boolean {
|
public hasAdminAccess(): boolean {
|
||||||
return this.role === 'ADMIN';
|
return this.role === 'ADMIN';
|
||||||
}
|
}
|
||||||
@ -181,7 +184,7 @@ User.init(
|
|||||||
type: DataTypes.STRING(20),
|
type: DataTypes.STRING(20),
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Extended SSO/Okta Fields (All Optional) ============
|
// ============ Extended SSO/Okta Fields (All Optional) ============
|
||||||
manager: {
|
manager: {
|
||||||
type: DataTypes.STRING(200),
|
type: DataTypes.STRING(200),
|
||||||
@ -227,14 +230,14 @@ User.init(
|
|||||||
field: 'ad_groups',
|
field: 'ad_groups',
|
||||||
comment: 'Active Directory group memberships from SSO (memberOf field) - JSON array'
|
comment: 'Active Directory group memberships from SSO (memberOf field) - JSON array'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Location Information (JSON object)
|
// Location Information (JSON object)
|
||||||
location: {
|
location: {
|
||||||
type: DataTypes.JSONB, // Use JSONB for PostgreSQL
|
type: DataTypes.JSONB, // Use JSONB for PostgreSQL
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: 'JSON object containing location details (city, state, country, office, timezone)'
|
comment: 'JSON object containing location details (city, state, country, office, timezone)'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Notification Preferences
|
// Notification Preferences
|
||||||
emailNotificationsEnabled: {
|
emailNotificationsEnabled: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
@ -257,7 +260,7 @@ User.init(
|
|||||||
field: 'in_app_notifications_enabled',
|
field: 'in_app_notifications_enabled',
|
||||||
comment: 'User preference for receiving in-app notifications'
|
comment: 'User preference for receiving in-app notifications'
|
||||||
},
|
},
|
||||||
|
|
||||||
isActive: {
|
isActive: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
|
|||||||
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 templateRoutes from './template.routes';
|
||||||
import dealerRoutes from './dealer.routes';
|
import dealerRoutes from './dealer.routes';
|
||||||
import dmsWebhookRoutes from './dmsWebhook.routes';
|
import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||||
|
import apiTokenRoutes from './apiToken.routes';
|
||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||||
|
|
||||||
@ -50,6 +51,7 @@ router.use('/dealer-claims', dealerClaimRoutes);
|
|||||||
router.use('/templates', templateRoutes);
|
router.use('/templates', templateRoutes);
|
||||||
router.use('/dealers', dealerRoutes);
|
router.use('/dealers', dealerRoutes);
|
||||||
router.use('/webhooks/dms', dmsWebhookRoutes);
|
router.use('/webhooks/dms', dmsWebhookRoutes);
|
||||||
|
router.use('/api-tokens', apiTokenRoutes);
|
||||||
|
|
||||||
// Add other route modules as they are implemented
|
// Add other route modules as they are implemented
|
||||||
// router.use('/approvals', approvalRoutes);
|
// router.use('/approvals', approvalRoutes);
|
||||||
|
|||||||
@ -159,6 +159,8 @@ async function runMigrations(): Promise<void> {
|
|||||||
const m44 = require('../migrations/20260123-fix-template-id-schema');
|
const m44 = require('../migrations/20260123-fix-template-id-schema');
|
||||||
const m45 = require('../migrations/20260209-add-gst-and-pwc-fields');
|
const m45 = require('../migrations/20260209-add-gst-and-pwc-fields');
|
||||||
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
|
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 = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ name: '2025103000-create-users', module: m0 },
|
||||||
@ -210,6 +212,8 @@ async function runMigrations(): Promise<void> {
|
|||||||
{ name: '20260123-fix-template-id-schema', module: m44 },
|
{ 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: '20260210-add-raw-pwc-responses', module: m46 },
|
{ 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
|
// 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 m43 from '../migrations/20260113-redesign-dealer-claim-history';
|
||||||
import * as m44 from '../migrations/20260123-fix-template-id-schema';
|
import * as m44 from '../migrations/20260123-fix-template-id-schema';
|
||||||
import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields';
|
import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields';
|
||||||
|
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -109,7 +110,8 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20250125-create-activity-types', module: m42 },
|
{ name: '20250125-create-activity-types', module: m42 },
|
||||||
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
|
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
|
||||||
{ name: '20260123-fix-template-id-schema', module: m44 },
|
{ 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 { WorkflowRequest } from '../models/WorkflowRequest';
|
||||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||||
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
import { DealerProposalDetails } from '../models/DealerProposalDetails';
|
||||||
@ -24,7 +27,7 @@ import { activityService } from './activity.service';
|
|||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { dmsIntegrationService } from './dmsIntegration.service';
|
import { dmsIntegrationService } from './dmsIntegration.service';
|
||||||
import { findDealerLocally } from './dealer.service';
|
import { findDealerLocally } from './dealer.service';
|
||||||
import logger from '../utils/logger';
|
|
||||||
|
|
||||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
@ -171,7 +174,7 @@ export class DealerClaimService {
|
|||||||
levelNumber: a.level,
|
levelNumber: a.level,
|
||||||
levelName: levelName,
|
levelName: levelName,
|
||||||
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
||||||
approverEmail: `system@${appDomain}`,
|
approverEmail: a.email,
|
||||||
approverName: a.name || a.email,
|
approverName: a.name || a.email,
|
||||||
tatHours: tatHours,
|
tatHours: tatHours,
|
||||||
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
|
// 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
|
// Use cost items from separate table
|
||||||
costBreakup = proposalData.costItems.map((item: any) => ({
|
costBreakup = proposalData.costItems.map((item: any) => ({
|
||||||
description: item.itemDescription || item.description,
|
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
|
// 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;
|
const expenseData = expense.toJSON ? expense.toJSON() : expense;
|
||||||
return {
|
return {
|
||||||
description: expenseData.description || '',
|
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,
|
requestId,
|
||||||
itemDescription: item.description || item.itemDescription || '',
|
itemDescription: item.description || item.itemDescription || '',
|
||||||
amount: Number(item.amount) || 0,
|
amount: Number(item.amount) || 0,
|
||||||
|
quantity: Number(item.quantity) || 1,
|
||||||
|
hsnCode: item.hsnCode || '',
|
||||||
gstRate: Number(item.gstRate) || 0,
|
gstRate: Number(item.gstRate) || 0,
|
||||||
gstAmt: Number(item.gstAmt) || 0,
|
gstAmt: Number(item.gstAmt) || 0,
|
||||||
cgstRate: Number(item.cgstRate) || 0,
|
cgstRate: Number(item.cgstRate) || 0,
|
||||||
@ -1423,6 +1443,8 @@ export class DealerClaimService {
|
|||||||
completionId,
|
completionId,
|
||||||
description: item.description,
|
description: 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,
|
gstRate: Number(item.gstRate) || 0,
|
||||||
gstAmt: Number(item.gstAmt) || 0,
|
gstAmt: Number(item.gstAmt) || 0,
|
||||||
cgstRate: Number(item.cgstRate) || 0,
|
cgstRate: Number(item.cgstRate) || 0,
|
||||||
@ -1914,7 +1936,15 @@ export class DealerClaimService {
|
|||||||
|| budgetTracking?.initialEstimatedBudget
|
|| budgetTracking?.initialEstimatedBudget
|
||||||
|| 0;
|
|| 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) {
|
if (!invoiceResult.success) {
|
||||||
throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`);
|
throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`);
|
||||||
@ -1922,7 +1952,7 @@ export class DealerClaimService {
|
|||||||
|
|
||||||
await ClaimInvoice.upsert({
|
await ClaimInvoice.upsert({
|
||||||
requestId,
|
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()),
|
invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()),
|
||||||
irn: invoiceResult.irn,
|
irn: invoiceResult.irn,
|
||||||
ackNo: invoiceResult.ackNo,
|
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
|
* 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
|
* 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-grid">
|
||||||
<div class="info-section">
|
<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 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>
|
<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>
|
||||||
<div class="info-section">
|
<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>
|
<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 { InternalOrder } from '../models/InternalOrder';
|
||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
||||||
|
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
|
||||||
|
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PWC E-Invoice Integration Service
|
* PWC E-Invoice Integration Service
|
||||||
@ -47,7 +49,7 @@ export class PWCIntegrationService {
|
|||||||
/**
|
/**
|
||||||
* Generate Signed Invoice via PWC API
|
* Generate Signed Invoice via PWC API
|
||||||
*/
|
*/
|
||||||
async generateSignedInvoice(requestId: string, amount?: number): Promise<{
|
async generateSignedInvoice(requestId: string, amount?: number, customInvoiceNumber?: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
irn?: string;
|
irn?: string;
|
||||||
ackNo?: string;
|
ackNo?: string;
|
||||||
@ -105,16 +107,90 @@ export class PWCIntegrationService {
|
|||||||
dealerStateCode = (dealer as any).stateCode;
|
dealerStateCode = (dealer as any).stateCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate tax amounts
|
// Fetch expenses if available
|
||||||
const gstRate = Number(activity.gstRate || 18);
|
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
|
const isIGST = dealerStateCode !== "33"; // If dealer state != Buyer state (33), it's IGST
|
||||||
|
|
||||||
const assAmt = finalAmount;
|
if (expenses && expenses.length > 0) {
|
||||||
const igstAmt = isIGST ? (finalAmount * (gstRate / 100)) : 0;
|
itemList = expenses.map((expense: any, index: number) => {
|
||||||
const cgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
|
const qty = expense.quantity || 1;
|
||||||
const sgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
|
const rate = Number(expense.amount) || 0;
|
||||||
const totalTax = igstAmt + cgstAmt + sgstAmt;
|
const gstRate = Number(expense.gstRate || 18);
|
||||||
const totalItemVal = finalAmount + totalTax;
|
|
||||||
|
// 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;
|
||||||
|
const sgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
|
||||||
|
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
|
// Construct PWC Payload - Aligned with sample format provided by user
|
||||||
const payload = [
|
const payload = [
|
||||||
@ -140,7 +216,7 @@ export class PWCIntegrationService {
|
|||||||
},
|
},
|
||||||
DocDtls: {
|
DocDtls: {
|
||||||
Typ: "Inv",
|
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
|
Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY
|
||||||
},
|
},
|
||||||
SellerDtls: {
|
SellerDtls: {
|
||||||
@ -157,7 +233,7 @@ export class PWCIntegrationService {
|
|||||||
Em: (dealer as any).dealerPrincipalEmailId || "Supplier@inv.com"
|
Em: (dealer as any).dealerPrincipalEmailId || "Supplier@inv.com"
|
||||||
},
|
},
|
||||||
BuyerDtls: {
|
BuyerDtls: {
|
||||||
Gstin: "{{BUYER_GSTIN}}", // Royal Enfield GST
|
Gstin: "33AAACE3882D1ZZ", // Royal Enfield GST (Tamil Nadu)
|
||||||
LglNm: "ROYAL ENFIELD (A UNIT OF EICHER MOTORS LTD)",
|
LglNm: "ROYAL ENFIELD (A UNIT OF EICHER MOTORS LTD)",
|
||||||
TrdNm: "ROYAL ENFIELD",
|
TrdNm: "ROYAL ENFIELD",
|
||||||
Addr1: "No. 2, Thiruvottiyur High Road",
|
Addr1: "No. 2, Thiruvottiyur High Road",
|
||||||
@ -166,31 +242,13 @@ export class PWCIntegrationService {
|
|||||||
Stcd: "33",
|
Stcd: "33",
|
||||||
Pos: "33"
|
Pos: "33"
|
||||||
},
|
},
|
||||||
ItemList: [
|
ItemList: 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)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
ValDtls: {
|
ValDtls: {
|
||||||
AssVal: formatAmount(assAmt),
|
AssVal: formatAmount(totalAssAmt),
|
||||||
IgstVal: formatAmount(igstAmt),
|
IgstVal: formatAmount(totalIgstAmt),
|
||||||
CgstVal: formatAmount(cgstAmt),
|
CgstVal: formatAmount(totalCgstAmt),
|
||||||
SgstVal: formatAmount(sgstAmt),
|
SgstVal: formatAmount(totalSgstAmt),
|
||||||
TotInvVal: formatAmount(totalItemVal)
|
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