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]
|
[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(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO emergency_contacts (
|
INSERT INTO emergency_contacts (
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ export const V2DemoFixture = {
|
|||||||
title: 'Commuter Support',
|
title: 'Commuter Support',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
benefitHistory: {
|
||||||
|
commuterActive: {
|
||||||
|
id: '9e46729a-ff53-4d1b-9110-7ee5c38a9001',
|
||||||
|
},
|
||||||
|
commuterPending: {
|
||||||
|
id: '9e46729a-ff53-4d1b-9110-7ee5c38a9002',
|
||||||
|
},
|
||||||
|
},
|
||||||
orders: {
|
orders: {
|
||||||
open: {
|
open: {
|
||||||
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
|
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',
|
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({
|
export const invoiceApproveSchema = z.object({
|
||||||
invoiceId: z.string().uuid(),
|
invoiceId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
@@ -111,6 +121,7 @@ export const coverageReviewSchema = z.object({
|
|||||||
assignmentId: z.string().uuid().optional(),
|
assignmentId: z.string().uuid().optional(),
|
||||||
rating: z.number().int().min(1).max(5),
|
rating: z.number().int().min(1).max(5),
|
||||||
markAsFavorite: z.boolean().optional(),
|
markAsFavorite: z.boolean().optional(),
|
||||||
|
markAsBlocked: z.boolean().optional(),
|
||||||
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
||||||
feedback: z.string().max(5000).optional(),
|
feedback: z.string().max(5000).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
createClientRecurringOrder,
|
createClientRecurringOrder,
|
||||||
createEditedOrderCopy,
|
createEditedOrderCopy,
|
||||||
createHub,
|
createHub,
|
||||||
|
createShiftManager,
|
||||||
declinePendingShift,
|
declinePendingShift,
|
||||||
disputeInvoice,
|
disputeInvoice,
|
||||||
quickSetStaffAvailability,
|
quickSetStaffAvailability,
|
||||||
@@ -69,6 +70,7 @@ import {
|
|||||||
profileExperienceSchema,
|
profileExperienceSchema,
|
||||||
pushTokenDeleteSchema,
|
pushTokenDeleteSchema,
|
||||||
pushTokenRegisterSchema,
|
pushTokenRegisterSchema,
|
||||||
|
shiftManagerCreateSchema,
|
||||||
shiftApplySchema,
|
shiftApplySchema,
|
||||||
shiftDecisionSchema,
|
shiftDecisionSchema,
|
||||||
shiftSubmitApprovalSchema,
|
shiftSubmitApprovalSchema,
|
||||||
@@ -95,6 +97,7 @@ const defaultHandlers = {
|
|||||||
createClientRecurringOrder,
|
createClientRecurringOrder,
|
||||||
createEditedOrderCopy,
|
createEditedOrderCopy,
|
||||||
createHub,
|
createHub,
|
||||||
|
createShiftManager,
|
||||||
declinePendingShift,
|
declinePendingShift,
|
||||||
disputeInvoice,
|
disputeInvoice,
|
||||||
quickSetStaffAvailability,
|
quickSetStaffAvailability,
|
||||||
@@ -260,6 +263,13 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
|||||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
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', {
|
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
|
||||||
schema: invoiceApproveSchema,
|
schema: invoiceApproveSchema,
|
||||||
policyAction: 'client.billing.write',
|
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, {
|
async function insertDomainEvent(client, {
|
||||||
tenantId,
|
tenantId,
|
||||||
aggregateType,
|
aggregateType,
|
||||||
@@ -986,6 +1010,11 @@ export async function assignStaffToShift(actor, payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId);
|
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;
|
let application = null;
|
||||||
if (payload.applicationId) {
|
if (payload.applicationId) {
|
||||||
application = await requireApplication(client, payload.tenantId, payload.applicationId);
|
application = await requireApplication(client, payload.tenantId, payload.applicationId);
|
||||||
|
|||||||
@@ -37,6 +37,30 @@ function ensureArray(value) {
|
|||||||
return Array.isArray(value) ? 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) {
|
function buildAssignmentReferencePayload(assignment) {
|
||||||
return {
|
return {
|
||||||
assignmentId: assignment.id,
|
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) {
|
export async function approveInvoice(actor, payload) {
|
||||||
const context = await requireClientContext(actor.uid);
|
const context = await requireClientContext(actor.uid);
|
||||||
return withTransaction(async (client) => {
|
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(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE staffs
|
UPDATE staffs
|
||||||
@@ -1517,6 +1730,7 @@ export async function rateWorkerFromCoverage(actor, payload) {
|
|||||||
staffId: payload.staffId,
|
staffId: payload.staffId,
|
||||||
rating: payload.rating,
|
rating: payload.rating,
|
||||||
markAsFavorite: payload.markAsFavorite ?? null,
|
markAsFavorite: payload.markAsFavorite ?? null,
|
||||||
|
markAsBlocked: payload.markAsBlocked ?? null,
|
||||||
issueFlags: ensureArray(payload.issueFlags || []),
|
issueFlags: ensureArray(payload.issueFlags || []),
|
||||||
feedback: payload.feedback || null,
|
feedback: payload.feedback || null,
|
||||||
};
|
};
|
||||||
@@ -2285,6 +2499,11 @@ export async function applyForShift(actor, payload) {
|
|||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid);
|
const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid);
|
||||||
const shiftRole = await requireShiftRoleForStaffApply(client, context.tenant.tenantId, payload.shiftId, payload.roleId, staff.id);
|
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(
|
const existingAssignment = await client.query(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ function createMobileHandlers() {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
costCenterId: payload.costCenterId,
|
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) => ({
|
approveInvoice: async (_actor, payload) => ({
|
||||||
invoiceId: payload.invoiceId,
|
invoiceId: payload.invoiceId,
|
||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
@@ -167,6 +174,25 @@ test('POST /commands/client/hubs returns injected hub response', async () => {
|
|||||||
assert.equal(res.body.name, 'Google North Hub');
|
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 () => {
|
test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => {
|
||||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
listAssignedShifts,
|
listAssignedShifts,
|
||||||
listBusinessAccounts,
|
listBusinessAccounts,
|
||||||
listBusinessTeamMembers,
|
listBusinessTeamMembers,
|
||||||
|
listBlockedStaff,
|
||||||
listCancelledShifts,
|
listCancelledShifts,
|
||||||
listCertificates,
|
listCertificates,
|
||||||
listCostCenters,
|
listCostCenters,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
listStaffAvailability,
|
listStaffAvailability,
|
||||||
listStaffBankAccounts,
|
listStaffBankAccounts,
|
||||||
listStaffBenefits,
|
listStaffBenefits,
|
||||||
|
listStaffBenefitHistory,
|
||||||
listTodayShifts,
|
listTodayShifts,
|
||||||
listVendorRoles,
|
listVendorRoles,
|
||||||
listVendors,
|
listVendors,
|
||||||
@@ -91,6 +93,7 @@ const defaultQueryService = {
|
|||||||
listAssignedShifts,
|
listAssignedShifts,
|
||||||
listBusinessAccounts,
|
listBusinessAccounts,
|
||||||
listBusinessTeamMembers,
|
listBusinessTeamMembers,
|
||||||
|
listBlockedStaff,
|
||||||
listCancelledShifts,
|
listCancelledShifts,
|
||||||
listCertificates,
|
listCertificates,
|
||||||
listCostCenters,
|
listCostCenters,
|
||||||
@@ -116,6 +119,7 @@ const defaultQueryService = {
|
|||||||
listStaffAvailability,
|
listStaffAvailability,
|
||||||
listStaffBankAccounts,
|
listStaffBankAccounts,
|
||||||
listStaffBenefits,
|
listStaffBenefits,
|
||||||
|
listStaffBenefitHistory,
|
||||||
listTodayShifts,
|
listTodayShifts,
|
||||||
listVendorRoles,
|
listVendorRoles,
|
||||||
listVendors,
|
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) => {
|
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const items = await queryService.listHubs(req.actor.uid);
|
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) => {
|
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
|
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ function metadataBoolean(metadata, key, fallback = false) {
|
|||||||
return fallback;
|
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) {
|
function getProfileCompletionFromMetadata(staffRow) {
|
||||||
const metadata = staffRow?.metadata || {};
|
const metadata = staffRow?.metadata || {};
|
||||||
const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/);
|
const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/);
|
||||||
@@ -513,10 +520,20 @@ export async function listHubManagers(actorUid, hubId) {
|
|||||||
hm.id AS "managerAssignmentId",
|
hm.id AS "managerAssignmentId",
|
||||||
bm.id AS "businessMembershipId",
|
bm.id AS "businessMembershipId",
|
||||||
u.id AS "managerId",
|
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
|
FROM hub_managers hm
|
||||||
JOIN business_memberships bm ON bm.id = hm.business_membership_id
|
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
|
WHERE hm.tenant_id = $1
|
||||||
AND hm.hub_id = $2
|
AND hm.hub_id = $2
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
@@ -1323,6 +1340,35 @@ export async function listStaffBenefits(actorUid) {
|
|||||||
return result.rows;
|
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) {
|
export async function listCoreTeam(actorUid) {
|
||||||
const context = await requireClientContext(actorUid);
|
const context = await requireClientContext(actorUid);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -1345,6 +1391,28 @@ export async function listCoreTeam(actorUid) {
|
|||||||
return result.rows;
|
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) {
|
export async function getOrderReorderPreview(actorUid, orderId) {
|
||||||
const context = await requireClientContext(actorUid);
|
const context = await requireClientContext(actorUid);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -1405,19 +1473,29 @@ export async function listBusinessTeamMembers(actorUid) {
|
|||||||
SELECT
|
SELECT
|
||||||
bm.id AS "businessMembershipId",
|
bm.id AS "businessMembershipId",
|
||||||
u.id AS "userId",
|
u.id AS "userId",
|
||||||
COALESCE(u.display_name, u.email) AS name,
|
u.display_name AS "displayName",
|
||||||
u.email,
|
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
|
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
|
WHERE bm.tenant_id = $1
|
||||||
AND bm.business_id = $2
|
AND bm.business_id = $2
|
||||||
AND bm.membership_status = 'ACTIVE'
|
AND bm.membership_status IN ('ACTIVE', 'INVITED')
|
||||||
ORDER BY name ASC
|
ORDER BY COALESCE(u.display_name, u.email, bm.invited_email) ASC
|
||||||
`,
|
`,
|
||||||
[context.tenant.tenantId, context.business.businessId]
|
[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 }) {
|
export async function getReportSummary(actorUid, { startDate, endDate }) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async function apiCall(path, {
|
|||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
body,
|
body,
|
||||||
expectedStatus = 200,
|
expectedStatus = 200,
|
||||||
|
allowFailure = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
@@ -49,6 +50,12 @@ async function apiCall(path, {
|
|||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const payload = await readJson(response);
|
const payload = await readJson(response);
|
||||||
|
if (allowFailure) {
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
body: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (response.status !== expectedStatus) {
|
if (response.status !== expectedStatus) {
|
||||||
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
|
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));
|
assert.ok(Array.isArray(teamMembers.items));
|
||||||
logStep('client.team-members.ok', { count: teamMembers.items.length });
|
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}`, {
|
const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, {
|
||||||
token: ownerSession.sessionToken,
|
token: ownerSession.sessionToken,
|
||||||
});
|
});
|
||||||
@@ -754,7 +784,9 @@ async function main() {
|
|||||||
});
|
});
|
||||||
const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id)
|
const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id)
|
||||||
|| openShifts.items[0];
|
|| openShifts.items[0];
|
||||||
|
const blockedApplyCandidate = openShifts.items.find((shift) => shift.shiftId !== openShift.shiftId);
|
||||||
assert.ok(openShift);
|
assert.ok(openShift);
|
||||||
|
assert.ok(blockedApplyCandidate);
|
||||||
logStep('staff.shifts.open.ok', { count: openShifts.items.length });
|
logStep('staff.shifts.open.ok', { count: openShifts.items.length });
|
||||||
|
|
||||||
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
||||||
@@ -858,6 +890,13 @@ async function main() {
|
|||||||
assert.ok(Array.isArray(benefits.items));
|
assert.ok(Array.isArray(benefits.items));
|
||||||
logStep('staff.profile.benefits.ok', { count: benefits.items.length });
|
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()}`, {
|
const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, {
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
@@ -1168,6 +1207,59 @@ async function main() {
|
|||||||
});
|
});
|
||||||
logStep('staff.shifts.request-swap.ok', requestedSwap);
|
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, {
|
const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, {
|
||||||
filename: 'profile-photo.jpg',
|
filename: 'profile-photo.jpg',
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ What was validated live against the deployed stack:
|
|||||||
- staff auth bootstrap
|
- staff auth bootstrap
|
||||||
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
|
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
|
||||||
- client coverage incident feed for geofence and override review
|
- client coverage incident feed for geofence and override review
|
||||||
|
- client blocked-staff review and invited shift-manager creation
|
||||||
- client hub, order, coverage review, device token, and late-worker cancellation flows
|
- client hub, order, coverage review, device token, and late-worker cancellation flows
|
||||||
- client invoice approve and dispute
|
- client invoice approve and dispute
|
||||||
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
|
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
|
||||||
|
- staff benefit history read model
|
||||||
- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request
|
- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request
|
||||||
- direct file upload helpers and verification job creation through the unified host
|
- direct file upload helpers and verification job creation through the unified host
|
||||||
- client and staff sign-out
|
- client and staff sign-out
|
||||||
@@ -143,6 +145,7 @@ Those routes still exist for backend/internal compatibility, but mobile/frontend
|
|||||||
|
|
||||||
- [Authentication](./authentication.md)
|
- [Authentication](./authentication.md)
|
||||||
- [Unified API](./unified-api.md)
|
- [Unified API](./unified-api.md)
|
||||||
|
- [Mobile Frontend Implementation Spec](./mobile-frontend-implementation-spec.md)
|
||||||
- [Staff Shifts](./staff-shifts.md)
|
- [Staff Shifts](./staff-shifts.md)
|
||||||
- [Core API](./core-api.md)
|
- [Core API](./core-api.md)
|
||||||
- [Command API](./command-api.md)
|
- [Command API](./command-api.md)
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Mobile Frontend Implementation Spec
|
||||||
|
|
||||||
|
This is the shortest path for frontend to implement the v2 mobile clients against the unified backend.
|
||||||
|
|
||||||
|
Base URL:
|
||||||
|
|
||||||
|
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
||||||
|
|
||||||
|
Use this doc together with:
|
||||||
|
|
||||||
|
- [Authentication](./authentication.md)
|
||||||
|
- [Unified API](./unified-api.md)
|
||||||
|
- [Staff Shifts](./staff-shifts.md)
|
||||||
|
|
||||||
|
## 1) Global rules
|
||||||
|
|
||||||
|
- Use unified routes only.
|
||||||
|
- Send `Authorization: Bearer <firebase-id-token>` on protected routes.
|
||||||
|
- Send `Idempotency-Key` on all write routes.
|
||||||
|
- Do not call `/query/*`, `/commands/*`, or `/core/*` directly from frontend.
|
||||||
|
|
||||||
|
## 2) Core model frontend should assume
|
||||||
|
|
||||||
|
- `order` is the client-facing request for staffing.
|
||||||
|
- `shift` is the concrete scheduled unit of work under an order.
|
||||||
|
- `shiftRole` is the role slot inside a shift that staff apply to.
|
||||||
|
- `assignment` is the worker-to-shift record once a worker is attached.
|
||||||
|
|
||||||
|
Important consequences:
|
||||||
|
|
||||||
|
- `GET /staff/shifts/open` returns open shift-role opportunities.
|
||||||
|
- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response.
|
||||||
|
- `GET /client/orders/view` is the timeline/read model for the client app.
|
||||||
|
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts.
|
||||||
|
|
||||||
|
## 3) Auth implementation
|
||||||
|
|
||||||
|
### Client app
|
||||||
|
|
||||||
|
- sign in with `POST /auth/client/sign-in`
|
||||||
|
- sign up with `POST /auth/client/sign-up`
|
||||||
|
- hydrate session with `GET /auth/session`
|
||||||
|
- sign out with `POST /auth/client/sign-out`
|
||||||
|
|
||||||
|
### Staff app
|
||||||
|
|
||||||
|
- start phone auth with `POST /auth/staff/phone/start`
|
||||||
|
- complete phone auth with `POST /auth/staff/phone/verify`
|
||||||
|
- hydrate session with `GET /auth/session`
|
||||||
|
- sign out with `POST /auth/staff/sign-out`
|
||||||
|
|
||||||
|
Token refresh:
|
||||||
|
|
||||||
|
- keep using Firebase client SDK refresh behavior
|
||||||
|
- there is no backend `/auth/refresh` route
|
||||||
|
|
||||||
|
## 4) Client app screen mapping
|
||||||
|
|
||||||
|
### Home / dashboard
|
||||||
|
|
||||||
|
- `GET /client/session`
|
||||||
|
- `GET /client/dashboard`
|
||||||
|
- `GET /client/reorders`
|
||||||
|
|
||||||
|
### Billing / payments
|
||||||
|
|
||||||
|
- `GET /client/billing/accounts`
|
||||||
|
- `GET /client/billing/invoices/pending`
|
||||||
|
- `GET /client/billing/invoices/history`
|
||||||
|
- `GET /client/billing/current-bill`
|
||||||
|
- `GET /client/billing/savings`
|
||||||
|
- `GET /client/billing/spend-breakdown`
|
||||||
|
- `POST /client/billing/invoices/:invoiceId/approve`
|
||||||
|
- `POST /client/billing/invoices/:invoiceId/dispute`
|
||||||
|
|
||||||
|
### Coverage
|
||||||
|
|
||||||
|
- `GET /client/coverage?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/coverage/stats?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/coverage/core-team?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/coverage/incidents?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`
|
||||||
|
- `GET /client/coverage/blocked-staff`
|
||||||
|
- `POST /client/coverage/reviews`
|
||||||
|
- `POST /client/coverage/late-workers/:assignmentId/cancel`
|
||||||
|
|
||||||
|
Use `POST /client/coverage/reviews` when the business is rating a worker after coverage review.
|
||||||
|
|
||||||
|
Payload may include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assignmentId": "uuid",
|
||||||
|
"rating": 4,
|
||||||
|
"comment": "Strong performance on the shift",
|
||||||
|
"markAsBlocked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `markAsBlocked` is `true`, backend blocks that worker for that business and rejects future apply or assign attempts until a later review sets `markAsBlocked: false`.
|
||||||
|
|
||||||
|
### Orders
|
||||||
|
|
||||||
|
- `GET /client/orders/view`
|
||||||
|
- `GET /client/orders/:orderId/reorder-preview`
|
||||||
|
- `POST /client/orders/one-time`
|
||||||
|
- `POST /client/orders/recurring`
|
||||||
|
- `POST /client/orders/permanent`
|
||||||
|
- `POST /client/orders/:orderId/edit`
|
||||||
|
- `POST /client/orders/:orderId/cancel`
|
||||||
|
|
||||||
|
Rapid-order flow:
|
||||||
|
|
||||||
|
- use `POST /rapid-orders/process` for the single-call transcribe-and-parse flow
|
||||||
|
|
||||||
|
### Hubs and managers
|
||||||
|
|
||||||
|
- `GET /client/hubs`
|
||||||
|
- `GET /client/cost-centers`
|
||||||
|
- `GET /client/hubs/:hubId/managers`
|
||||||
|
- `GET /client/team-members`
|
||||||
|
- `POST /client/shift-managers`
|
||||||
|
- `POST /client/hubs`
|
||||||
|
- `PUT /client/hubs/:hubId`
|
||||||
|
- `DELETE /client/hubs/:hubId`
|
||||||
|
- `POST /client/hubs/:hubId/assign-nfc`
|
||||||
|
- `POST /client/hubs/:hubId/managers`
|
||||||
|
|
||||||
|
`POST /client/shift-managers` is the fastest path to create an invited manager identity for a business. If `hubId` is provided, backend also links that manager to the hub.
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
|
||||||
|
- `GET /client/reports/summary?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/reports/daily-ops?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/reports/spend?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/reports/coverage?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/reports/forecast?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/reports/performance?date=YYYY-MM-DD`
|
||||||
|
- `GET /client/reports/no-show?date=YYYY-MM-DD`
|
||||||
|
|
||||||
|
## 5) Staff app screen mapping
|
||||||
|
|
||||||
|
### Home / dashboard
|
||||||
|
|
||||||
|
- `GET /staff/session`
|
||||||
|
- `GET /staff/dashboard`
|
||||||
|
- `GET /staff/profile-completion`
|
||||||
|
|
||||||
|
### Availability
|
||||||
|
|
||||||
|
- `GET /staff/availability`
|
||||||
|
- `PUT /staff/availability`
|
||||||
|
- `POST /staff/availability/quick-set`
|
||||||
|
|
||||||
|
### Find shifts
|
||||||
|
|
||||||
|
- `GET /staff/shifts/open`
|
||||||
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
|
||||||
|
- send the `roleId` from the open-shifts response
|
||||||
|
- this is the concrete `shift_roles.id`
|
||||||
|
|
||||||
|
### My shifts
|
||||||
|
|
||||||
|
- `GET /staff/shifts/pending`
|
||||||
|
- `GET /staff/shifts/assigned`
|
||||||
|
- `GET /staff/shifts/cancelled`
|
||||||
|
- `GET /staff/shifts/completed`
|
||||||
|
- `GET /staff/shifts/:shiftId`
|
||||||
|
- `POST /staff/shifts/:shiftId/accept`
|
||||||
|
- `POST /staff/shifts/:shiftId/decline`
|
||||||
|
- `POST /staff/shifts/:shiftId/request-swap`
|
||||||
|
- `POST /staff/shifts/:shiftId/submit-for-approval`
|
||||||
|
|
||||||
|
Current swap behavior:
|
||||||
|
|
||||||
|
- backend records the swap request
|
||||||
|
- assignment moves to `SWAP_REQUESTED`
|
||||||
|
- shift becomes visible in the replacement pool
|
||||||
|
|
||||||
|
Current limitation:
|
||||||
|
|
||||||
|
- full manager-side swap resolution lifecycle is not yet a separate frontend contract
|
||||||
|
|
||||||
|
### Clock in / clock out
|
||||||
|
|
||||||
|
- `GET /staff/clock-in/shifts/today`
|
||||||
|
- `GET /staff/clock-in/status`
|
||||||
|
- `POST /staff/clock-in`
|
||||||
|
- `POST /staff/clock-out`
|
||||||
|
- `POST /staff/location-streams`
|
||||||
|
|
||||||
|
Frontend should respect:
|
||||||
|
|
||||||
|
- `clockInMode`
|
||||||
|
- `allowClockInOverride`
|
||||||
|
- `latitude`
|
||||||
|
- `longitude`
|
||||||
|
- `geofenceRadiusMeters`
|
||||||
|
- `nfcTagId`
|
||||||
|
|
||||||
|
Clock-in proof rules:
|
||||||
|
|
||||||
|
- use `nfcTagId` for NFC clocking
|
||||||
|
- use `latitude`, `longitude`, and `accuracyMeters` for geolocation clocking
|
||||||
|
- send `overrideReason` only when a geofence override is allowed
|
||||||
|
- send `proofNonce` and `proofTimestamp` on attendance writes
|
||||||
|
|
||||||
|
### Payments
|
||||||
|
|
||||||
|
- `GET /staff/payments/summary`
|
||||||
|
- `GET /staff/payments/history`
|
||||||
|
- `GET /staff/payments/chart`
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
- `GET /staff/profile/sections`
|
||||||
|
- `GET /staff/profile/personal-info`
|
||||||
|
- `GET /staff/profile/industries`
|
||||||
|
- `GET /staff/profile/skills`
|
||||||
|
- `GET /staff/profile/documents`
|
||||||
|
- `GET /staff/profile/attire`
|
||||||
|
- `GET /staff/profile/tax-forms`
|
||||||
|
- `GET /staff/profile/emergency-contacts`
|
||||||
|
- `GET /staff/profile/certificates`
|
||||||
|
- `GET /staff/profile/bank-accounts`
|
||||||
|
- `GET /staff/profile/benefits`
|
||||||
|
- `GET /staff/profile/benefits/history`
|
||||||
|
- `GET /staff/profile/time-card`
|
||||||
|
- `GET /staff/profile/privacy`
|
||||||
|
- `PUT /staff/profile/personal-info`
|
||||||
|
- `PUT /staff/profile/experience`
|
||||||
|
- `PUT /staff/profile/locations`
|
||||||
|
- `POST /staff/profile/emergency-contacts`
|
||||||
|
- `PUT /staff/profile/emergency-contacts/:contactId`
|
||||||
|
- `PUT /staff/profile/tax-forms/:formType`
|
||||||
|
- `POST /staff/profile/tax-forms/:formType/submit`
|
||||||
|
- `POST /staff/profile/bank-accounts`
|
||||||
|
- `PUT /staff/profile/privacy`
|
||||||
|
|
||||||
|
Document model rule:
|
||||||
|
|
||||||
|
- `GET /staff/profile/documents` returns only documents
|
||||||
|
- `GET /staff/profile/attire` returns attire items
|
||||||
|
- `GET /staff/profile/tax-forms` returns tax-form rows
|
||||||
|
- `GET /staff/profile/certificates` returns certificates
|
||||||
|
|
||||||
|
### FAQ
|
||||||
|
|
||||||
|
- `GET /staff/faqs`
|
||||||
|
- `GET /staff/faqs/search?q=...`
|
||||||
|
|
||||||
|
## 6) Upload implementation
|
||||||
|
|
||||||
|
For documents, attire, and certificates:
|
||||||
|
|
||||||
|
1. `POST /upload-file`
|
||||||
|
2. `POST /create-signed-url`
|
||||||
|
3. upload file bytes to storage with the signed URL
|
||||||
|
4. `POST /verifications`
|
||||||
|
5. finalize with:
|
||||||
|
- `PUT /staff/profile/documents/:documentId/upload`
|
||||||
|
- `PUT /staff/profile/attire/:documentId/upload`
|
||||||
|
- `POST /staff/profile/certificates`
|
||||||
|
|
||||||
|
Use the verification-linked file as the source of truth.
|
||||||
|
|
||||||
|
## 7) What frontend should not assume
|
||||||
|
|
||||||
|
- do not assume order edit mutates past shifts
|
||||||
|
- do not assume swap resolution is complete beyond the request step
|
||||||
|
- do not assume raw `/query/*` or `/commands/*` routes are stable for app integration
|
||||||
|
- do not assume blocked workers can still apply to future shifts for that business
|
||||||
|
|
||||||
|
## 8) Demo reset
|
||||||
|
|
||||||
|
To reset dev demo data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.nvm/nvm.sh
|
||||||
|
nvm use 23.5.0
|
||||||
|
cd backend/command-api
|
||||||
|
npm run seed:v2-demo
|
||||||
|
```
|
||||||
@@ -44,6 +44,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
|
|||||||
- `GET /client/coverage/stats`
|
- `GET /client/coverage/stats`
|
||||||
- `GET /client/coverage/core-team`
|
- `GET /client/coverage/core-team`
|
||||||
- `GET /client/coverage/incidents`
|
- `GET /client/coverage/incidents`
|
||||||
|
- `GET /client/coverage/blocked-staff`
|
||||||
- `GET /client/hubs`
|
- `GET /client/hubs`
|
||||||
- `GET /client/cost-centers`
|
- `GET /client/cost-centers`
|
||||||
- `GET /client/vendors`
|
- `GET /client/vendors`
|
||||||
@@ -69,6 +70,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
|
|||||||
- `POST /client/orders/permanent`
|
- `POST /client/orders/permanent`
|
||||||
- `POST /client/orders/:orderId/edit`
|
- `POST /client/orders/:orderId/edit`
|
||||||
- `POST /client/orders/:orderId/cancel`
|
- `POST /client/orders/:orderId/cancel`
|
||||||
|
- `POST /client/shift-managers`
|
||||||
- `POST /client/hubs`
|
- `POST /client/hubs`
|
||||||
- `PUT /client/hubs/:hubId`
|
- `PUT /client/hubs/:hubId`
|
||||||
- `DELETE /client/hubs/:hubId`
|
- `DELETE /client/hubs/:hubId`
|
||||||
@@ -79,6 +81,33 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
|
|||||||
- `POST /client/coverage/reviews`
|
- `POST /client/coverage/reviews`
|
||||||
- `POST /client/coverage/late-workers/:assignmentId/cancel`
|
- `POST /client/coverage/late-workers/:assignmentId/cancel`
|
||||||
|
|
||||||
|
Coverage-review request payload may also send:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assignmentId": "uuid",
|
||||||
|
"rating": 2,
|
||||||
|
"comment": "Worker left the shift early without approval",
|
||||||
|
"markAsBlocked": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`.
|
||||||
|
|
||||||
|
Shift-manager creation example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"firstName": "Nora",
|
||||||
|
"lastName": "Lead",
|
||||||
|
"email": "nora.lead@example.com",
|
||||||
|
"phone": "+15550001234",
|
||||||
|
"hubId": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The manager is created as an invited business membership. If `hubId` is present, backend also links the manager to that hub.
|
||||||
|
|
||||||
## 3) Staff routes
|
## 3) Staff routes
|
||||||
|
|
||||||
### Staff reads
|
### Staff reads
|
||||||
@@ -109,6 +138,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
|
|||||||
- `GET /staff/profile/certificates`
|
- `GET /staff/profile/certificates`
|
||||||
- `GET /staff/profile/bank-accounts`
|
- `GET /staff/profile/bank-accounts`
|
||||||
- `GET /staff/profile/benefits`
|
- `GET /staff/profile/benefits`
|
||||||
|
- `GET /staff/profile/benefits/history`
|
||||||
- `GET /staff/profile/time-card`
|
- `GET /staff/profile/time-card`
|
||||||
- `GET /staff/profile/privacy`
|
- `GET /staff/profile/privacy`
|
||||||
- `GET /staff/faqs`
|
- `GET /staff/faqs`
|
||||||
@@ -197,6 +227,7 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
|
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
|
||||||
- Document routes now return only document rows. They do not mix in attire items anymore.
|
- Document routes now return only document rows. They do not mix in attire items anymore.
|
||||||
- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`.
|
- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`.
|
||||||
|
- Staff benefit activity should come from `GET /staff/profile/benefits/history`; the summary card should keep using `GET /staff/profile/benefits`.
|
||||||
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
|
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
|
||||||
- The frontend upload contract for documents, attire, and certificates is:
|
- The frontend upload contract for documents, attire, and certificates is:
|
||||||
1. `POST /upload-file`
|
1. `POST /upload-file`
|
||||||
@@ -224,6 +255,7 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
- send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
|
- send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
|
||||||
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
|
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
|
||||||
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
|
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
|
||||||
|
- `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business.
|
||||||
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time.
|
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time.
|
||||||
- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
|
- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
|
||||||
- Push delivery is backed by:
|
- Push delivery is backed by:
|
||||||
|
|||||||
Reference in New Issue
Block a user