feat(M08-D): 补加盟申请与跟进运营
This commit is contained in:
@@ -56,6 +56,7 @@ import {
|
||||
registerBusinessStatisticsRoutes,
|
||||
type BusinessStatisticsRouteOptions
|
||||
} from './routes/business-statistics.js';
|
||||
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -81,6 +82,7 @@ export interface BuildAppOptions {
|
||||
recharge?: RechargeRouteOptions;
|
||||
cleaning?: CleaningRouteOptions;
|
||||
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||
franchise?: FranchiseRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -188,6 +190,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.businessStatistics) {
|
||||
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
|
||||
}
|
||||
if (options.franchise) {
|
||||
await registerFranchiseRoutes(app, options.franchise);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
|
||||
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql'
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -84,9 +85,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
|
||||
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql'
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||
@@ -263,7 +266,8 @@ export async function executeMigrationPlan(
|
||||
1, 7, 5, 1,
|
||||
4, 1, 1, 1,
|
||||
2, 1, 1,
|
||||
1, 1, 1
|
||||
1, 1, 1,
|
||||
1, 2, 3, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
|
||||
export type FollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
|
||||
|
||||
export interface FranchiseApplicationInput {
|
||||
tenantId: string;
|
||||
submittedUserId?: string | null;
|
||||
city: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
message: string;
|
||||
source: 'MINIAPP' | 'ADMIN' | 'IMPORT';
|
||||
clientRequestId: string;
|
||||
traceId: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
interface ApplicationRow extends RowDataPacket {
|
||||
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
|
||||
contactPhone: string; message: string; source: string; status: FranchiseStatus;
|
||||
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
|
||||
nextFollowUpAt: Date | null; closedAt: Date | null; createdAt: Date; updatedAt: Date;
|
||||
}
|
||||
interface FollowUpRow extends RowDataPacket {
|
||||
id: string; actorUserId: string; actorName: string; followUpType: FollowUpType;
|
||||
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
|
||||
note: string; nextFollowUpAt: Date | null; createdAt: Date;
|
||||
}
|
||||
interface StatusRow extends RowDataPacket { status: FranchiseStatus }
|
||||
|
||||
export class FranchiseError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class FranchiseRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async submitApplication(input: FranchiseApplicationInput) {
|
||||
return this.transaction(async (connection) => {
|
||||
const applicationNo = `FR${Date.now().toString(36).toUpperCase()}${randomUUID().slice(0, 6).toUpperCase()}`;
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_franchise_applications
|
||||
(tenant_id, application_no, client_request_id, city, contact_name,
|
||||
contact_phone, message, source, submitted_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
||||
[input.tenantId, applicationNo, input.clientRequestId, input.city, input.contactName,
|
||||
input.contactPhone, input.message, input.source, input.submittedUserId ?? null]
|
||||
);
|
||||
const applicationId = String(result.insertId);
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { applicationNo: string }>>(
|
||||
`SELECT application_no AS applicationNo FROM qipai_franchise_applications
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, applicationId]
|
||||
);
|
||||
const created = String(rows[0]?.applicationNo ?? '') === applicationNo;
|
||||
if (created) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, ?, ?, 'FRANCHISE_APPLICATION_SUBMITTED', 'FRANCHISE_APPLICATION',
|
||||
?, ?, ?, ?, JSON_OBJECT('source', ?, 'city', ?))`,
|
||||
[input.tenantId, input.submittedUserId ? 'USER' : 'ANONYMOUS',
|
||||
input.submittedUserId ?? null, applicationId, input.traceId, input.ip,
|
||||
input.userAgent.slice(0, 255), input.source, input.city]
|
||||
);
|
||||
}
|
||||
return { applicationId, applicationNo: String(rows[0]?.applicationNo ?? applicationNo), idempotent: !created };
|
||||
});
|
||||
}
|
||||
|
||||
async listApplications(input: {
|
||||
tenantId: string; page: number; pageSize: number; status?: FranchiseStatus;
|
||||
assigneeUserId?: string; search?: string;
|
||||
}) {
|
||||
const where = ['a.tenant_id = ?', 'a.deleted_at IS NULL'];
|
||||
const params: Array<string> = [input.tenantId];
|
||||
if (input.status) { where.push('a.status = ?'); params.push(input.status); }
|
||||
if (input.assigneeUserId) { where.push('a.assignee_user_id = ?'); params.push(input.assigneeUserId); }
|
||||
if (input.search) {
|
||||
where.push('(a.application_no LIKE ? OR a.city LIKE ? OR a.contact_name LIKE ? OR a.contact_phone LIKE ?)');
|
||||
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||
}
|
||||
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_franchise_applications a WHERE ${where.join(' AND ')}`,
|
||||
params
|
||||
);
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const [rows] = await this.pool.execute<ApplicationRow[]>(
|
||||
`${this.applicationSelect()} WHERE ${where.join(' AND ')}
|
||||
ORDER BY FIELD(a.status, 'NEW', 'CONTACTED', 'QUALIFIED', 'CONVERTED', 'REJECTED'),
|
||||
a.next_follow_up_at IS NULL, a.next_follow_up_at, a.id DESC
|
||||
LIMIT ${input.pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
return { items: rows.map(normalizeApplication), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize };
|
||||
}
|
||||
|
||||
async getApplication(tenantId: string, applicationId: string) {
|
||||
const [rows] = await this.pool.execute<ApplicationRow[]>(
|
||||
`${this.applicationSelect()} WHERE a.tenant_id = ? AND a.id = ? AND a.deleted_at IS NULL`,
|
||||
[tenantId, applicationId]
|
||||
);
|
||||
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
|
||||
const [followUps] = await this.pool.execute<FollowUpRow[]>(
|
||||
`SELECT f.id, f.actor_user_id AS actorUserId, u.nickname AS actorName,
|
||||
f.follow_up_type AS followUpType, f.from_status AS fromStatus,
|
||||
f.to_status AS toStatus, f.note, f.next_follow_up_at AS nextFollowUpAt,
|
||||
f.created_at AS createdAt
|
||||
FROM qipai_franchise_follow_ups f
|
||||
INNER JOIN qipai_users u ON u.id = f.actor_user_id
|
||||
WHERE f.tenant_id = ? AND f.application_id = ? ORDER BY f.id DESC`,
|
||||
[tenantId, applicationId]
|
||||
);
|
||||
return { application: normalizeApplication(rows[0]), followUps: followUps.map((row) => ({ ...row, id: String(row.id), actorUserId: String(row.actorUserId) })) };
|
||||
}
|
||||
|
||||
async assignApplication(actor: ManagementActor, tenantId: string, applicationId: string, assigneeUserId: string | null) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockApplication(connection, tenantId, applicationId);
|
||||
if (assigneeUserId) {
|
||||
const [users] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_users WHERE tenant_id = ? AND id = ? AND user_type = 'STAFF'
|
||||
AND status = 'ACTIVE' AND deleted_at IS NULL`,
|
||||
[tenantId, assigneeUserId]
|
||||
);
|
||||
if (!users[0]) throw new FranchiseError('FRANCHISE_ASSIGNEE_INVALID');
|
||||
}
|
||||
await connection.execute(
|
||||
'UPDATE qipai_franchise_applications SET assignee_user_id = ? WHERE tenant_id = ? AND id = ?',
|
||||
[assigneeUserId, tenantId, applicationId]
|
||||
);
|
||||
await this.addEvent(connection, actor, tenantId, applicationId, 'ASSIGNMENT', null, null,
|
||||
assigneeUserId ? '分派加盟线索负责人' : '取消加盟线索分派', null);
|
||||
await this.audit(connection, actor, tenantId, 'FRANCHISE_APPLICATION_ASSIGNED', applicationId,
|
||||
{ assigneeUserId });
|
||||
return { applicationId, assigneeUserId };
|
||||
});
|
||||
}
|
||||
|
||||
async addFollowUp(actor: ManagementActor, tenantId: string, applicationId: string, input: {
|
||||
followUpType: Exclude<FollowUpType, 'ASSIGNMENT' | 'STATUS'>;
|
||||
note: string; nextFollowUpAt?: Date | null; status?: FranchiseStatus;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const current = await this.lockApplication(connection, tenantId, applicationId);
|
||||
const nextStatus = input.status ?? current.status;
|
||||
if (nextStatus !== current.status && !allowedTransitions[current.status].includes(nextStatus)) {
|
||||
throw new FranchiseError('FRANCHISE_STATUS_TRANSITION_INVALID');
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_franchise_applications SET status = ?, next_follow_up_at = ?,
|
||||
closed_at = CASE WHEN ? IN ('REJECTED', 'CONVERTED') THEN UTC_TIMESTAMP(3) ELSE NULL END
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[nextStatus, input.nextFollowUpAt ?? null, nextStatus, tenantId, applicationId]
|
||||
);
|
||||
const followUpId = await this.addEvent(connection, actor, tenantId, applicationId,
|
||||
input.followUpType, current.status, nextStatus, input.note, input.nextFollowUpAt ?? null);
|
||||
await this.audit(connection, actor, tenantId, 'FRANCHISE_FOLLOW_UP_ADDED', applicationId,
|
||||
{ followUpId, followUpType: input.followUpType, fromStatus: current.status, toStatus: nextStatus });
|
||||
return { applicationId, followUpId, status: nextStatus };
|
||||
});
|
||||
}
|
||||
|
||||
private applicationSelect() {
|
||||
return `SELECT a.id, a.tenant_id AS tenantId, a.application_no AS applicationNo,
|
||||
a.city, a.contact_name AS contactName, a.contact_phone AS contactPhone,
|
||||
a.message, a.source, a.status, a.assignee_user_id AS assigneeUserId,
|
||||
assignee.nickname AS assigneeName, a.submitted_user_id AS submittedUserId,
|
||||
a.next_follow_up_at AS nextFollowUpAt, a.closed_at AS closedAt,
|
||||
a.created_at AS createdAt, a.updated_at AS updatedAt
|
||||
FROM qipai_franchise_applications a
|
||||
LEFT JOIN qipai_users assignee ON assignee.id = a.assignee_user_id AND assignee.tenant_id = a.tenant_id`;
|
||||
}
|
||||
|
||||
private async lockApplication(connection: PoolConnection, tenantId: string, id: string) {
|
||||
const [rows] = await connection.execute<StatusRow[]>(
|
||||
'SELECT status FROM qipai_franchise_applications WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE',
|
||||
[tenantId, id]
|
||||
);
|
||||
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async addEvent(connection: PoolConnection, actor: ManagementActor, tenantId: string,
|
||||
applicationId: string, type: FollowUpType, fromStatus: FranchiseStatus | null,
|
||||
toStatus: FranchiseStatus | null, note: string, nextFollowUpAt: Date | null) {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_franchise_follow_ups
|
||||
(tenant_id, application_id, actor_user_id, follow_up_type, from_status,
|
||||
to_status, note, next_follow_up_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[tenantId, applicationId, actor.userId, type, fromStatus, toStatus, note, nextFollowUpAt]
|
||||
);
|
||||
return String(result.insertId);
|
||||
}
|
||||
|
||||
private async audit(connection: PoolConnection, actor: ManagementActor, tenantId: string,
|
||||
action: string, resourceId: string, metadata: object) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, ?,
|
||||
'FRANCHISE_APPLICATION', ?, ?, ?, ?, ?)`,
|
||||
[tenantId, actor.userId, action, resourceId, actor.traceId, actor.ip,
|
||||
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||
catch (error) { await connection.rollback(); throw error; }
|
||||
finally { connection.release(); }
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTransitions: Record<FranchiseStatus, FranchiseStatus[]> = {
|
||||
NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'],
|
||||
QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: []
|
||||
};
|
||||
|
||||
function normalizeApplication(row: ApplicationRow) {
|
||||
return { ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||
assigneeUserId: row.assigneeUserId === null ? null : String(row.assigneeUserId),
|
||||
submittedUserId: row.submittedUserId === null ? null : String(row.submittedUserId) };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { FranchiseError, type FranchiseRepository } from '../franchise/franchise-repository.js';
|
||||
import { AmbiguousAppTenantError } from '../tenancy/platform-config-repository.js';
|
||||
import type { PlatformConfigResolver } from './platform-bootstrap.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const status = z.enum(['NEW', 'CONTACTED', 'QUALIFIED', 'REJECTED', 'CONVERTED']);
|
||||
const contextHeaders = z.object({
|
||||
'x-wechat-appid': z.string().trim().min(6).max(64),
|
||||
'tenant-id': id.optional()
|
||||
});
|
||||
const applicationSchema = z.object({
|
||||
city: z.string().trim().min(1).max(64),
|
||||
contactName: z.string().trim().min(1).max(64),
|
||||
contactPhone: z.string().trim().regex(/^\+?[0-9 -]{6,32}$/),
|
||||
message: z.string().trim().max(1000).default(''),
|
||||
clientRequestId: z.string().trim().min(8).max(128)
|
||||
});
|
||||
const listSchema = z.object({
|
||||
tenantId: id.optional(), page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20), status: status.optional(),
|
||||
assigneeUserId: id.optional(), search: z.string().trim().max(64).optional()
|
||||
});
|
||||
const tenantQuery = z.object({ tenantId: id.optional() });
|
||||
const assignmentSchema = z.object({ tenantId: id.optional(), assigneeUserId: id.nullable() });
|
||||
const followUpSchema = z.object({
|
||||
tenantId: id.optional(), followUpType: z.enum(['CALL', 'WECHAT', 'MEETING', 'NOTE']),
|
||||
note: z.string().trim().min(1).max(1000), nextFollowUpAt: z.coerce.date().nullable().optional(),
|
||||
status: status.optional()
|
||||
});
|
||||
|
||||
export interface FranchiseRouteOptions {
|
||||
repository: Pick<FranchiseRepository, 'submitApplication' | 'listApplications'
|
||||
| 'getApplication' | 'assignApplication' | 'addFollowUp'>;
|
||||
platformConfig: PlatformConfigResolver;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerFranchiseRoutes(app: FastifyInstance, options: FranchiseRouteOptions) {
|
||||
app.post('/app-api/franchise-applications', async (request, reply) => {
|
||||
const headers = contextHeaders.safeParse(request.headers);
|
||||
const body = applicationSchema.safeParse(request.body);
|
||||
if (!headers.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => {
|
||||
let bootstrap;
|
||||
try {
|
||||
bootstrap = await options.platformConfig.resolveBootstrap(
|
||||
headers.data['x-wechat-appid'], headers.data['tenant-id']
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousAppTenantError) throw new FranchiseError('FRANCHISE_TENANT_SELECTION_REQUIRED');
|
||||
throw error;
|
||||
}
|
||||
if (!bootstrap) throw new FranchiseError('FRANCHISE_TENANT_NOT_FOUND');
|
||||
const auth = request.headers.authorization
|
||||
? await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret)
|
||||
: null;
|
||||
if (request.headers.authorization && !auth) throw new FranchiseError('FRANCHISE_SESSION_INVALID');
|
||||
const submittedUserId = auth?.session.tenantId === bootstrap.tenantId ? auth.session.user.id : null;
|
||||
return reply.status(201).send({ code: 0, data: await options.repository.submitApplication({
|
||||
tenantId: bootstrap.tenantId, submittedUserId, ...body.data, source: 'MINIAPP',
|
||||
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
|
||||
}), traceId: request.traceId });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/admin-api/franchise-applications', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const targetTenantId = resolveTenant(actor, query.data.tenantId);
|
||||
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({ code: 0,
|
||||
data: await options.repository.listApplications({ ...query.data, tenantId: targetTenantId }),
|
||||
traceId: request.traceId }));
|
||||
});
|
||||
|
||||
app.get('/admin-api/franchise-applications/:id', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const query = tenantQuery.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const targetTenantId = resolveTenant(actor, query.data.tenantId);
|
||||
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({ code: 0,
|
||||
data: await options.repository.getApplication(targetTenantId, params.data.id), traceId: request.traceId }));
|
||||
});
|
||||
|
||||
app.patch('/admin-api/franchise-applications/:id/assignee', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = assignmentSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const targetTenantId = resolveTenant(actor, body.data.tenantId);
|
||||
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({ code: 0,
|
||||
data: await options.repository.assignApplication(actor, targetTenantId, params.data.id,
|
||||
body.data.assigneeUserId), traceId: request.traceId }));
|
||||
});
|
||||
|
||||
app.post('/admin-api/franchise-applications/:id/follow-ups', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = followUpSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const targetTenantId = resolveTenant(actor, body.data.tenantId);
|
||||
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({ code: 0,
|
||||
data: await options.repository.addFollowUp(actor, targetTenantId, params.data.id, body.data),
|
||||
traceId: request.traceId }));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: FranchiseRouteOptions) {
|
||||
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
|
||||
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||
reply.status(403).send({ code: 'FRANCHISE_MANAGEMENT_FORBIDDEN', message: 'Franchise management permission is required.', traceId: request.traceId }); return null;
|
||||
}
|
||||
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
|
||||
}
|
||||
|
||||
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||
return isPlatform(actor.access) ? requested : null;
|
||||
}
|
||||
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try { return await work(); } catch (error) {
|
||||
if (!(error instanceof FranchiseError)) throw error;
|
||||
const statusCode = error.code === 'FRANCHISE_SESSION_INVALID' ? 401
|
||||
: error.code.endsWith('_NOT_FOUND') ? 404
|
||||
: error.code.includes('FORBIDDEN') ? 403
|
||||
: error.code.endsWith('_SELECTION_REQUIRED') ? 409 : 400;
|
||||
return reply.status(statusCode).send({ code: error.code, message: 'The franchise request is invalid or not allowed.', traceId });
|
||||
}
|
||||
}
|
||||
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_FRANCHISE_REQUEST', message: 'The franchise request is invalid.', traceId }); }
|
||||
function forbidden(reply: FastifyReply, traceId: string) { return reply.status(403).send({ code: 'FRANCHISE_TENANT_FORBIDDEN', message: 'The tenant is outside the allowed scope.', traceId }); }
|
||||
+10
-1
@@ -41,11 +41,13 @@ import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'
|
||||
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
||||
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
||||
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
const authRepository = new AuthRepository(pool);
|
||||
const accessControl = new RbacRepository(pool);
|
||||
const platformConfigRepository = new PlatformConfigRepository(pool);
|
||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const walletLedgerService = new WalletLedgerService(pool);
|
||||
const marketingBenefits = new MarketingBenefitService(pool);
|
||||
@@ -69,7 +71,7 @@ const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
|
||||
const app = await buildApp({
|
||||
config,
|
||||
mqtt,
|
||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
||||
platformConfigRepository,
|
||||
platformManagement: {
|
||||
repository: new PlatformAdminRepository(pool),
|
||||
authRepository,
|
||||
@@ -216,6 +218,13 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
franchise: {
|
||||
repository: new FranchiseRepository(pool),
|
||||
platformConfig: platformConfigRepository,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
Reference in New Issue
Block a user