feat(api): add M5 coverage controls and frontend spec
This commit is contained in:
@@ -256,6 +256,36 @@ async function main() {
|
||||
[fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_benefit_history (
|
||||
id, tenant_id, staff_id, benefit_id, benefit_type, title, status,
|
||||
effective_at, ended_at, tracked_hours, target_hours, notes, metadata
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
$1, $3, $4, $5, 'COMMUTER', $6, 'PENDING',
|
||||
NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days', 18, 40,
|
||||
'Hours were below threshold for payout window.',
|
||||
'{"source":"seed-v2-demo","period":"previous"}'::jsonb
|
||||
),
|
||||
(
|
||||
$2, $3, $4, $5, 'COMMUTER', $6, 'ACTIVE',
|
||||
NOW() - INTERVAL '9 days', NULL, 32, 40,
|
||||
'Current active commuter stipend tracking.',
|
||||
'{"source":"seed-v2-demo","period":"current"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
fixture.benefitHistory.commuterPending.id,
|
||||
fixture.benefitHistory.commuterActive.id,
|
||||
fixture.tenant.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.benefits.commuter.id,
|
||||
fixture.benefits.commuter.title,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO emergency_contacts (
|
||||
|
||||
@@ -99,6 +99,14 @@ export const V2DemoFixture = {
|
||||
title: 'Commuter Support',
|
||||
},
|
||||
},
|
||||
benefitHistory: {
|
||||
commuterActive: {
|
||||
id: '9e46729a-ff53-4d1b-9110-7ee5c38a9001',
|
||||
},
|
||||
commuterPending: {
|
||||
id: '9e46729a-ff53-4d1b-9110-7ee5c38a9002',
|
||||
},
|
||||
},
|
||||
orders: {
|
||||
open: {
|
||||
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE IF NOT EXISTS staff_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
reason TEXT,
|
||||
issue_flags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_blocks_business_staff
|
||||
ON staff_blocks (business_id, staff_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_staff_blocks_business_created_at
|
||||
ON staff_blocks (business_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_benefit_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
benefit_id UUID REFERENCES staff_benefits(id) ON DELETE SET NULL,
|
||||
benefit_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
effective_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
tracked_hours INTEGER NOT NULL DEFAULT 0,
|
||||
target_hours INTEGER,
|
||||
notes TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_staff_effective_at
|
||||
ON staff_benefit_history (staff_id, effective_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_tenant_benefit_type
|
||||
ON staff_benefit_history (tenant_id, benefit_type, effective_at DESC);
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,13 @@ function createMobileHandlers() {
|
||||
name: payload.name,
|
||||
costCenterId: payload.costCenterId,
|
||||
}),
|
||||
createShiftManager: async (_actor, payload) => ({
|
||||
businessMembershipId: 'membership-1',
|
||||
membershipStatus: 'INVITED',
|
||||
invitedEmail: payload.email,
|
||||
fullName: `${payload.firstName} ${payload.lastName}`,
|
||||
managerAssignmentId: payload.hubId ? 'hub-manager-1' : null,
|
||||
}),
|
||||
approveInvoice: async (_actor, payload) => ({
|
||||
invoiceId: payload.invoiceId,
|
||||
status: 'APPROVED',
|
||||
@@ -167,6 +174,25 @@ test('POST /commands/client/hubs returns injected hub response', async () => {
|
||||
assert.equal(res.body.name, 'Google North Hub');
|
||||
});
|
||||
|
||||
test('POST /commands/client/shift-managers creates an invited manager profile', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/shift-managers')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'shift-manager-create-1')
|
||||
.send({
|
||||
hubId: '11111111-1111-4111-8111-111111111111',
|
||||
email: 'manager@example.com',
|
||||
firstName: 'Shift',
|
||||
lastName: 'Lead',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.membershipStatus, 'INVITED');
|
||||
assert.equal(res.body.invitedEmail, 'manager@example.com');
|
||||
assert.equal(res.body.managerAssignmentId, 'hub-manager-1');
|
||||
});
|
||||
|
||||
test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
|
||||
Reference in New Issue
Block a user