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

@@ -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 (

View File

@@ -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',

View File

@@ -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);

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

View File

@@ -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)

View File

@@ -28,6 +28,7 @@ import {
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listBlockedStaff,
listCancelledShifts,
listCertificates,
listCostCenters,
@@ -53,6 +54,7 @@ import {
listStaffAvailability,
listStaffBankAccounts,
listStaffBenefits,
listStaffBenefitHistory,
listTodayShifts,
listVendorRoles,
listVendors,
@@ -91,6 +93,7 @@ const defaultQueryService = {
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listBlockedStaff,
listCancelledShifts,
listCertificates,
listCostCenters,
@@ -116,6 +119,7 @@ const defaultQueryService = {
listStaffAvailability,
listStaffBankAccounts,
listStaffBenefits,
listStaffBenefitHistory,
listTodayShifts,
listVendorRoles,
listVendors,
@@ -253,6 +257,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/client/coverage/blocked-staff', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listBlockedStaff(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubs(req.actor.uid);
@@ -622,6 +635,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/staff/profile/benefits/history', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listStaffBenefitHistory(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);

View File

@@ -52,6 +52,13 @@ function metadataBoolean(metadata, key, fallback = false) {
return fallback;
}
function membershipDisplayName(row) {
const firstName = row?.firstName || row?.metadata?.firstName || null;
const lastName = row?.lastName || row?.metadata?.lastName || null;
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
return fullName || row?.name || row?.displayName || row?.email || row?.invitedEmail || null;
}
function getProfileCompletionFromMetadata(staffRow) {
const metadata = staffRow?.metadata || {};
const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/);
@@ -513,10 +520,20 @@ export async function listHubManagers(actorUid, hubId) {
hm.id AS "managerAssignmentId",
bm.id AS "businessMembershipId",
u.id AS "managerId",
COALESCE(u.display_name, u.email) AS name
u.display_name AS "displayName",
u.email,
bm.invited_email AS "invitedEmail",
bm.membership_status AS "membershipStatus",
bm.metadata,
COALESCE(
NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''),
u.display_name,
u.email,
bm.invited_email
) AS name
FROM hub_managers hm
JOIN business_memberships bm ON bm.id = hm.business_membership_id
JOIN users u ON u.id = bm.user_id
LEFT JOIN users u ON u.id = bm.user_id
WHERE hm.tenant_id = $1
AND hm.hub_id = $2
ORDER BY name ASC
@@ -1323,6 +1340,35 @@ export async function listStaffBenefits(actorUid) {
return result.rows;
}
export async function listStaffBenefitHistory(actorUid, { limit, offset } = {}) {
const context = await requireStaffContext(actorUid);
const safeLimit = parseLimit(limit, 20, 100);
const safeOffset = Number.isFinite(Number(offset)) && Number(offset) >= 0 ? Number(offset) : 0;
const result = await query(
`
SELECT
id AS "historyId",
benefit_id AS "benefitId",
benefit_type AS "benefitType",
title,
status,
effective_at AS "effectiveAt",
ended_at AS "endedAt",
tracked_hours AS "trackedHours",
target_hours AS "targetHours",
notes,
metadata
FROM staff_benefit_history
WHERE tenant_id = $1
AND staff_id = $2
ORDER BY effective_at DESC, created_at DESC
LIMIT $3 OFFSET $4
`,
[context.tenant.tenantId, context.staff.staffId, safeLimit, safeOffset]
);
return result.rows;
}
export async function listCoreTeam(actorUid) {
const context = await requireClientContext(actorUid);
const result = await query(
@@ -1345,6 +1391,28 @@ export async function listCoreTeam(actorUid) {
return result.rows;
}
export async function listBlockedStaff(actorUid) {
const context = await requireClientContext(actorUid);
const result = await query(
`
SELECT
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
sb.reason,
sb.issue_flags AS "issueFlags",
sb.created_at AS "blockedAt"
FROM staff_blocks sb
JOIN staffs st ON st.id = sb.staff_id
WHERE sb.tenant_id = $1
AND sb.business_id = $2
ORDER BY sb.created_at DESC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId]
);
return result.rows;
}
export async function getOrderReorderPreview(actorUid, orderId) {
const context = await requireClientContext(actorUid);
const result = await query(
@@ -1405,19 +1473,29 @@ export async function listBusinessTeamMembers(actorUid) {
SELECT
bm.id AS "businessMembershipId",
u.id AS "userId",
COALESCE(u.display_name, u.email) AS name,
u.display_name AS "displayName",
u.email,
bm.business_role AS role
bm.invited_email AS "invitedEmail",
bm.business_role AS role,
bm.membership_status AS "membershipStatus",
bm.metadata
FROM business_memberships bm
JOIN users u ON u.id = bm.user_id
LEFT JOIN users u ON u.id = bm.user_id
WHERE bm.tenant_id = $1
AND bm.business_id = $2
AND bm.membership_status = 'ACTIVE'
ORDER BY name ASC
AND bm.membership_status IN ('ACTIVE', 'INVITED')
ORDER BY COALESCE(u.display_name, u.email, bm.invited_email) ASC
`,
[context.tenant.tenantId, context.business.businessId]
);
return result.rows;
return result.rows.map((row) => ({
businessMembershipId: row.businessMembershipId,
userId: row.userId,
name: membershipDisplayName(row),
email: row.email || row.invitedEmail || null,
role: row.role,
membershipStatus: row.membershipStatus,
}));
}
export async function getReportSummary(actorUid, { startDate, endDate }) {

View File

@@ -37,6 +37,7 @@ async function apiCall(path, {
idempotencyKey,
body,
expectedStatus = 200,
allowFailure = false,
} = {}) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
@@ -49,6 +50,12 @@ async function apiCall(path, {
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await readJson(response);
if (allowFailure) {
return {
statusCode: response.status,
body: payload,
};
}
if (response.status !== expectedStatus) {
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
}
@@ -381,6 +388,29 @@ async function main() {
assert.ok(Array.isArray(teamMembers.items));
logStep('client.team-members.ok', { count: teamMembers.items.length });
const createdShiftManager = await apiCall('/client/shift-managers', {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('create-shift-manager'),
body: {
hubId: fixture.clockPoint.id,
email: `smoke.manager.${Date.now()}@krowd.com`,
firstName: 'Smoke',
lastName: 'Manager',
phone: '+15550009999',
},
});
assert.ok(createdShiftManager.businessMembershipId);
assert.equal(createdShiftManager.membershipStatus, 'INVITED');
assert.ok(createdShiftManager.managerAssignmentId);
logStep('client.shift-manager.create.ok', createdShiftManager);
const teamMembersAfterCreate = await apiCall('/client/team-members', {
token: ownerSession.sessionToken,
});
assert.ok(teamMembersAfterCreate.items.some((item) => item.businessMembershipId === createdShiftManager.businessMembershipId));
logStep('client.team-members.after-create.ok', { count: teamMembersAfterCreate.items.length });
const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, {
token: ownerSession.sessionToken,
});
@@ -754,7 +784,9 @@ async function main() {
});
const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id)
|| openShifts.items[0];
const blockedApplyCandidate = openShifts.items.find((shift) => shift.shiftId !== openShift.shiftId);
assert.ok(openShift);
assert.ok(blockedApplyCandidate);
logStep('staff.shifts.open.ok', { count: openShifts.items.length });
const pendingShifts = await apiCall('/staff/shifts/pending', {
@@ -858,6 +890,13 @@ async function main() {
assert.ok(Array.isArray(benefits.items));
logStep('staff.profile.benefits.ok', { count: benefits.items.length });
const benefitHistory = await apiCall('/staff/profile/benefits/history?limit=10', {
token: staffAuth.idToken,
});
assert.ok(Array.isArray(benefitHistory.items));
assert.ok(benefitHistory.items.length >= 1);
logStep('staff.profile.benefits.history.ok', { count: benefitHistory.items.length });
const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, {
token: staffAuth.idToken,
});
@@ -1168,6 +1207,59 @@ async function main() {
});
logStep('staff.shifts.request-swap.ok', requestedSwap);
const blockedReview = await apiCall('/client/coverage/reviews', {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('coverage-block'),
body: {
staffId: fixture.staff.ana.id,
assignmentId: fixture.assignments.completedAna.id,
rating: 2,
markAsBlocked: true,
markAsFavorite: false,
issueFlags: ['LATE_CLOCK_IN'],
feedback: 'Smoke blocked staff test',
},
});
assert.equal(blockedReview.markAsBlocked, true);
logStep('client.coverage.block.ok', blockedReview);
const blockedStaff = await apiCall('/client/coverage/blocked-staff', {
token: ownerSession.sessionToken,
});
assert.ok(blockedStaff.items.some((item) => item.staffId === fixture.staff.ana.id));
logStep('client.coverage.blocked-staff.ok', { count: blockedStaff.items.length });
const blockedApplyAttempt = await apiCall(`/staff/shifts/${blockedApplyCandidate.shiftId}/apply`, {
method: 'POST',
token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-apply-blocked'),
body: {
roleId: blockedApplyCandidate.roleId,
},
allowFailure: true,
});
assert.equal(blockedApplyAttempt.statusCode, 409);
assert.equal(blockedApplyAttempt.body?.code, 'STAFF_BLOCKED');
logStep('staff.shifts.apply-blocked.ok', blockedApplyAttempt.body);
const unblockedReview = await apiCall('/client/coverage/reviews', {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('coverage-unblock'),
body: {
staffId: fixture.staff.ana.id,
assignmentId: fixture.assignments.completedAna.id,
rating: 5,
markAsBlocked: false,
markAsFavorite: true,
issueFlags: [],
feedback: 'Smoke unblock cleanup',
},
});
assert.equal(unblockedReview.markAsBlocked, false);
logStep('client.coverage.unblock.ok', unblockedReview);
const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, {
filename: 'profile-photo.jpg',
contentType: 'image/jpeg',