fix(GRO-778): exempt /dev-session from validatePortalSession middleware (#329)
fix(GRO-778): exempt /dev-session from validatePortalSession middleware
This commit was merged in pull request #329.
This commit is contained in:
@@ -213,7 +213,11 @@ petsRouter.post(
|
|||||||
|
|
||||||
// Delete the previous photo from storage to avoid orphaned objects
|
// Delete the previous photo from storage to avoid orphaned objects
|
||||||
if (pet.photoKey) {
|
if (pet.photoKey) {
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => {
|
|||||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||||
|
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(pets)
|
.update(pets)
|
||||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||||
|
|||||||
@@ -9,6 +9,68 @@ import type { PortalEnv } from "../middleware/portalSession.js";
|
|||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
export const portalRouter = new Hono<PortalEnv>();
|
||||||
|
|
||||||
|
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||||
|
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||||
|
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||||
|
const devSessionSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post(
|
||||||
|
"/dev-session",
|
||||||
|
zValidator("json", devSessionSchema),
|
||||||
|
async (c) => {
|
||||||
|
if (process.env.AUTH_DISABLED !== "true") {
|
||||||
|
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, body.clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) {
|
||||||
|
return c.json({ error: "Client not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
let staffId = DEMO_STAFF_ID;
|
||||||
|
const [demoStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!demoStaff) {
|
||||||
|
const [firstStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.active, true))
|
||||||
|
.limit(1);
|
||||||
|
if (!firstStaff) {
|
||||||
|
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||||
|
}
|
||||||
|
staffId = firstStaff.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.insert(impersonationSessions)
|
||||||
|
.values({
|
||||||
|
staffId,
|
||||||
|
clientId: body.clientId,
|
||||||
|
reason: "dev-mode-client-portal",
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(session, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// Apply middleware to all portal routes
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
@@ -460,73 +522,4 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
|
||||||
|
|
||||||
const devSessionSchema = z.object({
|
|
||||||
clientId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/dev-session",
|
|
||||||
zValidator("json", devSessionSchema),
|
|
||||||
async (c) => {
|
|
||||||
if (process.env.AUTH_DISABLED !== "true") {
|
|
||||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
// Verify client exists
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, body.clientId))
|
|
||||||
.limit(1);
|
|
||||||
if (!client) {
|
|
||||||
return c.json({ error: "Client not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a staff record to associate with the dev impersonation session.
|
|
||||||
// Use the demo-manager if it exists (created by seed with known ID),
|
|
||||||
// otherwise fall back to the first active staff record.
|
|
||||||
// This avoids hardcoding a UUID that may not exist in all environments.
|
|
||||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
let staffId = DEMO_STAFF_ID;
|
|
||||||
const [demoStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!demoStaff) {
|
|
||||||
// Fall back to any active staff member
|
|
||||||
const [firstStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.active, true))
|
|
||||||
.limit(1);
|
|
||||||
if (!firstStaff) {
|
|
||||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
|
||||||
}
|
|
||||||
staffId = firstStaff.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.insert(impersonationSessions)
|
|
||||||
.values({
|
|
||||||
staffId,
|
|
||||||
clientId: body.clientId,
|
|
||||||
reason: "dev-mode-client-portal",
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return c.json(session, 201);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
+54
-45
@@ -200,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appointments = pgTable("appointments", {
|
export const appointments = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"appointments",
|
||||||
clientId: uuid("client_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
.references(() => clients.id, { onDelete: "restrict" }),
|
clientId: uuid("client_id")
|
||||||
petId: uuid("pet_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => clients.id, { onDelete: "restrict" }),
|
||||||
.references(() => pets.id, { onDelete: "restrict" }),
|
petId: uuid("pet_id")
|
||||||
serviceId: uuid("service_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => pets.id, { onDelete: "restrict" }),
|
||||||
.references(() => services.id, { onDelete: "restrict" }),
|
serviceId: uuid("service_id")
|
||||||
staffId: uuid("staff_id").references(() => staff.id, {
|
.notNull()
|
||||||
onDelete: "set null",
|
.references(() => services.id, { onDelete: "restrict" }),
|
||||||
}),
|
staffId: uuid("staff_id").references(() => staff.id, {
|
||||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
onDelete: "set null",
|
||||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
}),
|
||||||
onDelete: "set null",
|
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||||
}),
|
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
onDelete: "set null",
|
||||||
startTime: timestamp("start_time").notNull(),
|
}),
|
||||||
endTime: timestamp("end_time").notNull(),
|
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||||
notes: text("notes"),
|
startTime: timestamp("start_time").notNull(),
|
||||||
// Override price at time of booking (null = use service base price)
|
endTime: timestamp("end_time").notNull(),
|
||||||
priceCents: integer("price_cents"),
|
notes: text("notes"),
|
||||||
// Recurring series support
|
// Override price at time of booking (null = use service base price)
|
||||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
priceCents: integer("price_cents"),
|
||||||
onDelete: "set null",
|
// Recurring series support
|
||||||
}),
|
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||||
seriesIndex: integer("series_index"),
|
onDelete: "set null",
|
||||||
// Multi-pet group booking: links this appointment to others in the same visit
|
}),
|
||||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
seriesIndex: integer("series_index"),
|
||||||
onDelete: "set null",
|
// Multi-pet group booking: links this appointment to others in the same visit
|
||||||
}),
|
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||||
// Customer confirmation/cancellation tracking
|
onDelete: "set null",
|
||||||
// Values: "pending" | "confirmed" | "cancelled"
|
}),
|
||||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
// Customer confirmation/cancellation tracking
|
||||||
confirmedAt: timestamp("confirmed_at"),
|
// Values: "pending" | "confirmed" | "cancelled"
|
||||||
cancelledAt: timestamp("cancelled_at"),
|
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||||
// Token for tokenized email confirm/cancel links (no auth required)
|
confirmedAt: timestamp("confirmed_at"),
|
||||||
confirmationToken: text("confirmation_token").unique(),
|
cancelledAt: timestamp("cancelled_at"),
|
||||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
// Token for tokenized email confirm/cancel links (no auth required)
|
||||||
customerNotes: text("customer_notes"),
|
confirmationToken: text("confirmation_token").unique(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
customerNotes: text("customer_notes"),
|
||||||
});
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_appointments_client_id").on(t.clientId),
|
||||||
|
index("idx_appointments_staff_id").on(t.staffId),
|
||||||
|
index("idx_appointments_start_time").on(t.startTime),
|
||||||
|
index("idx_appointments_status").on(t.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const invoices = pgTable(
|
export const invoices = pgTable(
|
||||||
"invoices",
|
"invoices",
|
||||||
|
|||||||
Reference in New Issue
Block a user