feat(api): add M5 coverage controls and frontend spec

This commit is contained in:
zouantchaw
2026-03-18 08:18:50 +01:00
parent 008dd7efb1
commit 32f6cd55c8
14 changed files with 894 additions and 8 deletions

View File

@@ -97,6 +97,16 @@ export const hubAssignManagerSchema = z.object({
message: 'businessMembershipId or managerUserId is required',
});
export const shiftManagerCreateSchema = z.object({
hubId: z.string().uuid().optional(),
email: z.string().email(),
firstName: z.string().min(1).max(120),
lastName: z.string().min(1).max(120),
phone: z.string().min(7).max(40).optional(),
role: z.enum(['manager', 'viewer']).optional(),
metadata: z.record(z.any()).optional(),
});
export const invoiceApproveSchema = z.object({
invoiceId: z.string().uuid(),
});
@@ -111,6 +121,7 @@ export const coverageReviewSchema = z.object({
assignmentId: z.string().uuid().optional(),
rating: z.number().int().min(1).max(5),
markAsFavorite: z.boolean().optional(),
markAsBlocked: z.boolean().optional(),
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
feedback: z.string().max(5000).optional(),
});

View File

@@ -17,6 +17,7 @@ import {
createClientRecurringOrder,
createEditedOrderCopy,
createHub,
createShiftManager,
declinePendingShift,
disputeInvoice,
quickSetStaffAvailability,
@@ -69,6 +70,7 @@ import {
profileExperienceSchema,
pushTokenDeleteSchema,
pushTokenRegisterSchema,
shiftManagerCreateSchema,
shiftApplySchema,
shiftDecisionSchema,
shiftSubmitApprovalSchema,
@@ -95,6 +97,7 @@ const defaultHandlers = {
createClientRecurringOrder,
createEditedOrderCopy,
createHub,
createShiftManager,
declinePendingShift,
disputeInvoice,
quickSetStaffAvailability,
@@ -260,6 +263,13 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/shift-managers', {
schema: shiftManagerCreateSchema,
policyAction: 'client.hubs.update',
resource: 'hub_manager',
handler: handlers.createShiftManager,
}));
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
schema: invoiceApproveSchema,
policyAction: 'client.billing.write',

View File

@@ -44,6 +44,30 @@ async function ensureActorUser(client, actor) {
);
}
async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) {
const blocked = await client.query(
`
SELECT id, reason, issue_flags
FROM staff_blocks
WHERE tenant_id = $1
AND business_id = $2
AND staff_id = $3
LIMIT 1
`,
[tenantId, businessId, staffId]
);
if (blocked.rowCount > 0) {
throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, {
businessId,
staffId,
blockId: blocked.rows[0].id,
reason: blocked.rows[0].reason || null,
issueFlags: Array.isArray(blocked.rows[0].issue_flags) ? blocked.rows[0].issue_flags : [],
});
}
}
async function insertDomainEvent(client, {
tenantId,
aggregateType,
@@ -986,6 +1010,11 @@ export async function assignStaffToShift(actor, payload) {
}
const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId);
await ensureStaffNotBlockedByBusiness(client, {
tenantId: shift.tenant_id,
businessId: shift.business_id,
staffId: workforce.staff_id,
});
let application = null;
if (payload.applicationId) {
application = await requireApplication(client, payload.tenantId, payload.applicationId);

View File

@@ -37,6 +37,30 @@ function ensureArray(value) {
return Array.isArray(value) ? value : [];
}
async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) {
const blocked = await client.query(
`
SELECT id, reason, issue_flags
FROM staff_blocks
WHERE tenant_id = $1
AND business_id = $2
AND staff_id = $3
LIMIT 1
`,
[tenantId, businessId, staffId]
);
if (blocked.rowCount > 0) {
throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, {
businessId,
staffId,
blockId: blocked.rows[0].id,
reason: blocked.rows[0].reason || null,
issueFlags: ensureArray(blocked.rows[0].issue_flags || []),
});
}
}
function buildAssignmentReferencePayload(assignment) {
return {
assignmentId: assignment.id,
@@ -1342,6 +1366,149 @@ export async function assignHubManager(actor, payload) {
});
}
export async function createShiftManager(actor, payload) {
const context = await requireClientContext(actor.uid);
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
const invitedEmail = payload.email.trim().toLowerCase();
const fullName = `${payload.firstName} ${payload.lastName}`.trim();
const userLookup = await client.query(
`
SELECT id
FROM users
WHERE LOWER(email) = $1
LIMIT 1
`,
[invitedEmail]
);
const existingMembership = await client.query(
`
SELECT id, user_id, membership_status, metadata
FROM business_memberships
WHERE tenant_id = $1
AND business_id = $2
AND (
LOWER(invited_email) = $3
OR ($4::text IS NOT NULL AND user_id = $4)
)
LIMIT 1
FOR UPDATE
`,
[context.tenant.tenantId, context.business.businessId, invitedEmail, userLookup.rows[0]?.id || null]
);
const membershipMetadata = {
...(existingMembership.rows[0]?.metadata || {}),
firstName: payload.firstName,
lastName: payload.lastName,
fullName,
phone: normalizePhone(payload.phone),
source: 'mobile-api',
createdBy: actor.uid,
...(payload.metadata || {}),
};
let businessMembershipId;
let membershipStatus;
if (existingMembership.rowCount > 0) {
const result = await client.query(
`
UPDATE business_memberships
SET user_id = COALESCE(user_id, $2),
invited_email = $3,
membership_status = CASE
WHEN COALESCE(user_id, $2) IS NOT NULL THEN 'ACTIVE'
ELSE membership_status
END,
business_role = $4,
metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING id, membership_status
`,
[
existingMembership.rows[0].id,
userLookup.rows[0]?.id || null,
invitedEmail,
payload.role || 'manager',
JSON.stringify(membershipMetadata),
]
);
businessMembershipId = result.rows[0].id;
membershipStatus = result.rows[0].membership_status;
} else {
const result = await client.query(
`
INSERT INTO business_memberships (
tenant_id,
business_id,
user_id,
invited_email,
membership_status,
business_role,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
RETURNING id, membership_status
`,
[
context.tenant.tenantId,
context.business.businessId,
userLookup.rows[0]?.id || null,
invitedEmail,
userLookup.rows[0]?.id ? 'ACTIVE' : 'INVITED',
payload.role || 'manager',
JSON.stringify(membershipMetadata),
]
);
businessMembershipId = result.rows[0].id;
membershipStatus = result.rows[0].membership_status;
}
let managerAssignmentId = null;
if (payload.hubId) {
const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true });
const assigned = await client.query(
`
INSERT INTO hub_managers (tenant_id, hub_id, business_membership_id)
VALUES ($1, $2, $3)
ON CONFLICT (hub_id, business_membership_id) DO UPDATE
SET updated_at = NOW()
RETURNING id
`,
[context.tenant.tenantId, hub.id, businessMembershipId]
);
managerAssignmentId = assigned.rows[0].id;
}
await insertDomainEvent(client, {
tenantId: context.tenant.tenantId,
aggregateType: 'business_membership',
aggregateId: businessMembershipId,
eventType: 'SHIFT_MANAGER_CREATED',
actorUserId: actor.uid,
payload: {
invitedEmail,
fullName,
hubId: payload.hubId || null,
membershipStatus,
},
});
return {
businessMembershipId,
membershipStatus,
invitedEmail,
fullName,
role: payload.role || 'manager',
managerAssignmentId,
};
});
}
export async function approveInvoice(actor, payload) {
const context = await requireClientContext(actor.uid);
return withTransaction(async (client) => {
@@ -1483,6 +1650,52 @@ export async function rateWorkerFromCoverage(actor, payload) {
);
}
if (payload.markAsBlocked === true) {
await client.query(
`
INSERT INTO staff_blocks (
tenant_id,
business_id,
staff_id,
created_by_user_id,
reason,
issue_flags,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
ON CONFLICT (business_id, staff_id) DO UPDATE
SET reason = EXCLUDED.reason,
issue_flags = EXCLUDED.issue_flags,
metadata = COALESCE(staff_blocks.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
`,
[
context.tenant.tenantId,
context.business.businessId,
payload.staffId,
actor.uid,
payload.feedback || null,
JSON.stringify(ensureArray(payload.issueFlags || [])),
JSON.stringify({
blockedByCoverageReview: true,
assignmentId: assignment.id,
}),
]
);
}
if (payload.markAsBlocked === false) {
await client.query(
`
DELETE FROM staff_blocks
WHERE tenant_id = $1
AND business_id = $2
AND staff_id = $3
`,
[context.tenant.tenantId, context.business.businessId, payload.staffId]
);
}
await client.query(
`
UPDATE staffs
@@ -1517,6 +1730,7 @@ export async function rateWorkerFromCoverage(actor, payload) {
staffId: payload.staffId,
rating: payload.rating,
markAsFavorite: payload.markAsFavorite ?? null,
markAsBlocked: payload.markAsBlocked ?? null,
issueFlags: ensureArray(payload.issueFlags || []),
feedback: payload.feedback || null,
};
@@ -2285,6 +2499,11 @@ export async function applyForShift(actor, payload) {
await ensureActorUser(client, actor);
const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid);
const shiftRole = await requireShiftRoleForStaffApply(client, context.tenant.tenantId, payload.shiftId, payload.roleId, staff.id);
await ensureStaffNotBlockedByBusiness(client, {
tenantId: context.tenant.tenantId,
businessId: shiftRole.business_id,
staffId: staff.id,
});
const existingAssignment = await client.query(
`
SELECT id