feat(api): complete M5 swap and dispatch backend slice

This commit is contained in:
zouantchaw
2026-03-18 10:40:04 +01:00
parent 32f6cd55c8
commit 26a853184f
18 changed files with 2170 additions and 109 deletions

View File

@@ -1,12 +1,18 @@
import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js';
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixture.mjs';
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
const staffPhone = process.env.V2_DEMO_STAFF_PHONE || '+15557654321';
const ownerUid = fixture.users.businessOwner.id;
const ownerEmail = fixture.users.businessOwner.email;
const staffUid = fixture.users.staffAna.id;
const staffEmail = fixture.users.staffAna.email;
const staffPhone = process.env.V2_DEMO_STAFF_PHONE || fixture.staff.ana.phone;
const staffBenUid = fixture.users.staffBen.id;
const staffBenEmail = fixture.users.staffBen.email;
const staffBenPhone = process.env.V2_DEMO_STAFF_BEN_PHONE || fixture.staff.ben.phone;
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
const staffBenPassword = process.env.V2_DEMO_STAFF_BEN_PASSWORD || 'Demo2026!';
function ensureAdminApp() {
if (getApps().length === 0) {
@@ -19,42 +25,8 @@ function getAdminAuth() {
return getAuth();
}
async function ensureUser({ email, password, displayName }) {
try {
const signedIn = await signInWithPassword({ email, password });
return {
uid: signedIn.localId,
email,
password,
created: false,
displayName,
};
} catch (error) {
const message = error?.message || '';
if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) {
throw error;
}
}
try {
const signedUp = await signUpWithPassword({ email, password });
return {
uid: signedUp.localId,
email,
password,
created: true,
displayName,
};
} catch (error) {
const message = error?.message || '';
if (message.includes('EMAIL_EXISTS')) {
throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`);
}
throw error;
}
}
async function getUserByPhoneNumber(phoneNumber) {
if (!phoneNumber) return null;
try {
return await getAdminAuth().getUserByPhoneNumber(phoneNumber);
} catch (error) {
@@ -63,57 +35,90 @@ async function getUserByPhoneNumber(phoneNumber) {
}
}
async function reconcileStaffPhoneIdentity({ uid, email, displayName, phoneNumber }) {
async function getUserByEmail(email) {
try {
return await getAdminAuth().getUserByEmail(email);
} catch (error) {
if (error?.code === 'auth/user-not-found') return null;
throw error;
}
}
async function ensureManagedUser({ uid, email, password, displayName, phoneNumber }) {
const auth = getAdminAuth();
const current = await auth.getUser(uid);
const existingPhoneUser = await getUserByPhoneNumber(phoneNumber);
let deletedConflictingUid = null;
if (existingPhoneUser && existingPhoneUser.uid !== uid) {
deletedConflictingUid = existingPhoneUser.uid;
await auth.deleteUser(existingPhoneUser.uid);
const existingByEmail = await getUserByEmail(email);
if (existingByEmail && existingByEmail.uid !== uid) {
await auth.deleteUser(existingByEmail.uid);
}
const existingByPhone = await getUserByPhoneNumber(phoneNumber);
if (existingByPhone && existingByPhone.uid !== uid) {
await auth.deleteUser(existingByPhone.uid);
}
const updatePayload = {};
if (current.displayName !== displayName) updatePayload.displayName = displayName;
if (current.email !== email) updatePayload.email = email;
if (current.phoneNumber !== phoneNumber) updatePayload.phoneNumber = phoneNumber;
if (Object.keys(updatePayload).length > 0) {
await auth.updateUser(uid, updatePayload);
try {
await auth.updateUser(uid, {
email,
password,
displayName,
...(phoneNumber ? { phoneNumber } : {}),
emailVerified: true,
disabled: false,
});
} catch (error) {
if (error?.code !== 'auth/user-not-found') {
throw error;
}
await auth.createUser({
uid,
email,
password,
displayName,
...(phoneNumber ? { phoneNumber } : {}),
emailVerified: true,
disabled: false,
});
}
const reconciled = await auth.getUser(uid);
const user = await auth.getUser(uid);
return {
uid: reconciled.uid,
email: reconciled.email,
phoneNumber: reconciled.phoneNumber,
deletedConflictingUid,
uid: user.uid,
email: user.email,
phoneNumber: user.phoneNumber,
displayName: user.displayName,
created: true,
};
}
async function main() {
const owner = await ensureUser({
const owner = await ensureManagedUser({
uid: ownerUid,
email: ownerEmail,
password: ownerPassword,
displayName: 'Legendary Demo Owner V2',
displayName: fixture.users.businessOwner.displayName,
});
const staff = await ensureUser({
const staff = await ensureManagedUser({
uid: staffUid,
email: staffEmail,
password: staffPassword,
displayName: 'Ana Barista V2',
});
const reconciledStaff = await reconcileStaffPhoneIdentity({
uid: staff.uid,
email: staff.email,
displayName: staff.displayName,
displayName: fixture.users.staffAna.displayName,
phoneNumber: staffPhone,
});
const staffBen = await ensureManagedUser({
uid: staffBenUid,
email: staffBenEmail,
password: staffBenPassword,
displayName: fixture.users.staffBen.displayName,
phoneNumber: staffBenPhone,
});
// eslint-disable-next-line no-console
console.log(JSON.stringify({ owner, staff: { ...staff, ...reconciledStaff } }, null, 2));
console.log(JSON.stringify({
owner,
staff,
staffBen,
}, null, 2));
}
main().catch((error) => {

View File

@@ -5,8 +5,10 @@ import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixt
const unifiedBaseUrl = process.env.UNIFIED_API_BASE_URL || 'https://krow-api-v2-e3g6witsvq-uc.a.run.app';
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
const staffBenEmail = process.env.V2_DEMO_STAFF_BEN_EMAIL || 'ben.barista+v2@krowd.com';
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
const staffBenPassword = process.env.V2_DEMO_STAFF_BEN_PASSWORD || 'Demo2026!';
function uniqueKey(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -175,13 +177,22 @@ async function signInStaff() {
});
}
async function signInStaffBen() {
return signInWithPassword({
email: staffBenEmail,
password: staffBenPassword,
});
}
async function main() {
const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`;
const ownerSession = await signInClient();
const staffAuth = await signInStaff();
const staffBenAuth = await signInStaffBen();
assert.ok(ownerSession.sessionToken);
assert.ok(staffAuth.idToken);
assert.ok(staffBenAuth.idToken);
assert.equal(ownerSession.business.businessId, fixture.business.id);
logStep('auth.client.sign-in.ok', {
tenantId: ownerSession.tenant.tenantId,
@@ -191,6 +202,10 @@ async function main() {
uid: staffAuth.localId,
email: staffEmail,
});
logStep('auth.staff-b.password-sign-in.ok', {
uid: staffBenAuth.localId,
email: staffBenEmail,
});
const authSession = await apiCall('/auth/session', {
token: ownerSession.sessionToken,
@@ -342,6 +357,13 @@ async function main() {
assert.ok(Array.isArray(coreTeam.items));
logStep('client.coverage.core-team.ok', { count: coreTeam.items.length });
const dispatchTeams = await apiCall('/client/coverage/dispatch-teams', {
token: ownerSession.sessionToken,
});
assert.ok(Array.isArray(dispatchTeams.items));
assert.ok(dispatchTeams.items.length >= 2);
logStep('client.coverage.dispatch-teams.ok', { count: dispatchTeams.items.length });
const coverageIncidentsBefore = await apiCall(`/client/coverage/incidents?${reportWindow}`, {
token: ownerSession.sessionToken,
});
@@ -1197,7 +1219,7 @@ async function main() {
assert.equal(submittedCompletedShift.submitted, true);
logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift);
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/request-swap`, {
method: 'POST',
token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-swap'),
@@ -1207,6 +1229,54 @@ async function main() {
});
logStep('staff.shifts.request-swap.ok', requestedSwap);
const benOpenShifts = await apiCall('/staff/shifts/open?limit=10', {
token: staffBenAuth.idToken,
});
const benSwapShift = benOpenShifts.items.find((item) => item.shiftId === fixture.shifts.swapEligible.id);
assert.ok(benSwapShift);
assert.equal(benSwapShift.swapRequestId, requestedSwap.swapRequestId);
assert.equal(benSwapShift.dispatchTeam, 'CERTIFIED_LOCATION');
logStep('staff-b.shifts.open-swap.ok', benSwapShift);
const dispatchCandidates = await apiCall(`/client/coverage/dispatch-candidates?shiftId=${fixture.shifts.swapEligible.id}&roleId=${fixture.shiftRoles.swapEligibleBarista.id}`, {
token: ownerSession.sessionToken,
});
assert.ok(Array.isArray(dispatchCandidates.items));
assert.ok(dispatchCandidates.items.length >= 1);
assert.equal(dispatchCandidates.items[0].staffId, fixture.staff.ben.id);
logStep('client.coverage.dispatch-candidates.ok', { count: dispatchCandidates.items.length });
const benSwapApplication = await apiCall(`/staff/shifts/${fixture.shifts.swapEligible.id}/apply`, {
method: 'POST',
token: staffBenAuth.idToken,
idempotencyKey: uniqueKey('staff-b-shift-swap-apply'),
body: {
roleId: fixture.shiftRoles.swapEligibleBarista.id,
},
});
assert.ok(benSwapApplication.applicationId);
logStep('staff-b.shifts.apply-swap.ok', benSwapApplication);
const swapRequests = await apiCall('/client/coverage/swap-requests?status=OPEN', {
token: ownerSession.sessionToken,
});
const openSwapRequest = swapRequests.items.find((item) => item.swapRequestId === requestedSwap.swapRequestId);
assert.ok(openSwapRequest);
assert.ok(openSwapRequest.candidates.some((candidate) => candidate.staffId === fixture.staff.ben.id));
logStep('client.coverage.swap-requests.ok', { count: swapRequests.items.length });
const resolvedSwap = await apiCall(`/client/coverage/swap-requests/${requestedSwap.swapRequestId}/resolve`, {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('client-swap-resolve'),
body: {
applicationId: benSwapApplication.applicationId,
note: 'Smoke resolved swap request',
},
});
assert.equal(resolvedSwap.status, 'RESOLVED');
logStep('client.coverage.swap-resolve.ok', resolvedSwap);
const blockedReview = await apiCall('/client/coverage/reviews', {
method: 'POST',
token: ownerSession.sessionToken,