feat(api): add M5 coverage controls and frontend spec
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user