Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c89c2fd6b4 | |||
| 203b600713 | |||
| b230e015c2 | |||
| 53b2dc6067 | |||
| 1bdfa9f3d2 | |||
| 369c2ce182 | |||
| 5e24678fa5 | |||
| c438f5772c |
@@ -1,27 +0,0 @@
|
|||||||
# The current version of the config schema
|
|
||||||
version: 1
|
|
||||||
# What protocol to use when performing git operations. Supported values: ssh, https
|
|
||||||
git_protocol: https
|
|
||||||
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
|
|
||||||
editor:
|
|
||||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
|
||||||
prompt: enabled
|
|
||||||
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
|
||||||
prefer_editor_prompt: disabled
|
|
||||||
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
|
||||||
pager:
|
|
||||||
# Aliases allow you to create nicknames for gh commands
|
|
||||||
aliases:
|
|
||||||
co: pr checkout
|
|
||||||
# The path to a unix socket through which to send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
|
|
||||||
http_unix_socket:
|
|
||||||
# What web browser gh should use when opening URLs. If blank, will refer to environment.
|
|
||||||
browser:
|
|
||||||
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
|
|
||||||
color_labels: disabled
|
|
||||||
# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
|
|
||||||
accessible_colors: disabled
|
|
||||||
# Whether an accessible prompter should be used. Supported values: enabled, disabled
|
|
||||||
accessible_prompter: disabled
|
|
||||||
# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
|
|
||||||
spinner: enabled
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
github.com:
|
|
||||||
users:
|
|
||||||
groombook-engineer[bot]:
|
|
||||||
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
|
|
||||||
user: groombook-engineer[bot]
|
|
||||||
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
|
|
||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const appointmentGroupsRouter = new Hono();
|
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const groupConditions = clientId
|
const groupConditions = clientId
|
||||||
? [eq(appointmentGroups.clientId, clientId)]
|
? [eq(appointmentGroups.clientId, clientId)]
|
||||||
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => !from || g.appointments.length > 0);
|
.filter((g) => !from || g.appointments.length > 0);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
return c.json(
|
||||||
|
result.filter((g) =>
|
||||||
|
g.appointments.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
serviceId: appointments.serviceId,
|
serviceId: appointments.serviceId,
|
||||||
serviceName: services.name,
|
serviceName: services.name,
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
|
batherStaffId: appointments.batherStaffId,
|
||||||
staffName: staff.name,
|
staffName: staff.name,
|
||||||
status: appointments.status,
|
status: appointments.status,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
.where(eq(appointments.groupId, id))
|
.where(eq(appointments.groupId, id))
|
||||||
.orderBy(appointments.startTime);
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isGroomer &&
|
||||||
|
!groupAppts.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({ name: clients.name, email: clients.email })
|
.select({ name: clients.name, email: clients.email })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
|
|||||||
zValidator("json", createGroupSchema),
|
zValidator("json", createGroupSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
if (staffRow?.role === "groomer") {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Forbidden: groomers cannot create group bookings" },
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const startTime = new Date(body.startTime);
|
const startTime = new Date(body.startTime);
|
||||||
|
|
||||||
@@ -244,6 +276,28 @@ appointmentGroupsRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.select({ id: appointmentGroups.id })
|
||||||
|
.from(appointmentGroups)
|
||||||
|
.where(eq(appointmentGroups.id, id));
|
||||||
|
if (!group) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const groupAppts = await db
|
||||||
|
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.groupId, id));
|
||||||
|
if (
|
||||||
|
!groupAppts.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(appointmentGroups)
|
.update(appointmentGroups)
|
||||||
@@ -261,6 +315,8 @@ appointmentGroupsRouter.patch(
|
|||||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({ id: appointmentGroups.id })
|
.select({ id: appointmentGroups.id })
|
||||||
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
|||||||
.where(eq(appointmentGroups.id, id));
|
.where(eq(appointmentGroups.id, id));
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
if (!group) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const groupAppts = await db
|
||||||
|
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.groupId, id));
|
||||||
|
if (
|
||||||
|
!groupAppts.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ const createAppointmentSchema = z.object({
|
|||||||
frequencyWeeks: z.number().int().min(1).max(52),
|
frequencyWeeks: z.number().int().min(1).max(52),
|
||||||
count: z.number().int().min(2).max(52),
|
count: z.number().int().min(2).max(52),
|
||||||
})
|
})
|
||||||
|
.refine(
|
||||||
|
(r) => r.frequencyWeeks * r.count <= 52,
|
||||||
|
{ message: "Recurrence series must not exceed 1 year" }
|
||||||
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,6 +167,29 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check batherStaffId conflicts if set
|
||||||
|
if (apptFields.batherStaffId) {
|
||||||
|
const conflicts = await tx
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
|
),
|
||||||
|
lt(appointments.startTime, end),
|
||||||
|
gte(appointments.endTime, start),
|
||||||
|
ne(appointments.status, "cancelled"),
|
||||||
|
ne(appointments.status, "no_show"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!recurrence) {
|
if (!recurrence) {
|
||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
@@ -461,6 +488,34 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check batherStaffId conflicts if being updated or already set
|
||||||
|
const batherStaffId =
|
||||||
|
updateFields.batherStaffId !== undefined
|
||||||
|
? updateFields.batherStaffId
|
||||||
|
: current.batherStaffId;
|
||||||
|
if (batherStaffId) {
|
||||||
|
const conflicts = await tx
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, batherStaffId),
|
||||||
|
eq(appointments.batherStaffId, batherStaffId)
|
||||||
|
),
|
||||||
|
lt(appointments.startTime, end),
|
||||||
|
gte(appointments.endTime, start),
|
||||||
|
ne(appointments.status, "cancelled"),
|
||||||
|
ne(appointments.status, "no_show"),
|
||||||
|
ne(appointments.id, id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set(update)
|
.set(update)
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const bookingSchema = z.object({
|
const bookingSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
startTime: z.string().datetime(),
|
startTime: z.string().datetime().refine(
|
||||||
|
(dt) => new Date(dt) > new Date(),
|
||||||
|
{ message: "Appointment must be in the future" }
|
||||||
|
),
|
||||||
clientName: z.string().min(1).max(200),
|
clientName: z.string().min(1).max(200),
|
||||||
clientEmail: z.string().email(),
|
clientEmail: z.string().email(),
|
||||||
clientPhone: z.string().max(50).optional(),
|
clientPhone: z.string().max(50).optional(),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono();
|
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createLogSchema = z.object({
|
const createLogSchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const petId = c.req.query("petId");
|
const petId = c.req.query("petId");
|
||||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(groomingVisitLogs)
|
.from(groomingVisitLogs)
|
||||||
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
|
|||||||
zValidator("json", createLogSchema),
|
zValidator("json", createLogSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { groomedAt, ...rest } = c.req.valid("json");
|
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
if (appointmentId) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.id, appointmentId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
} else {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(groomingVisitLogs)
|
.insert(groomingVisitLogs)
|
||||||
.values({
|
.values({
|
||||||
...rest,
|
...rest,
|
||||||
|
petId,
|
||||||
|
appointmentId: appointmentId ?? null,
|
||||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -47,10 +107,37 @@ groomingLogsRouter.post(
|
|||||||
|
|
||||||
groomingLogsRouter.delete("/:id", async (c) => {
|
groomingLogsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [row] = await db
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
const [log] = await db
|
||||||
|
.select()
|
||||||
|
.from(groomingVisitLogs)
|
||||||
|
.where(eq(groomingVisitLogs.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!log) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, log.petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
.delete(groomingVisitLogs)
|
.delete(groomingVisitLogs)
|
||||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
.where(eq(groomingVisitLogs.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,53 +44,61 @@ const updateInvoiceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List invoices
|
// List invoices
|
||||||
invoicesRouter.get("/", async (c) => {
|
const listInvoicesQuerySchema = z.object({
|
||||||
const db = getDb();
|
clientId: z.string().uuid().optional(),
|
||||||
const clientId = c.req.query("clientId");
|
appointmentId: z.string().uuid().optional(),
|
||||||
const appointmentId = c.req.query("appointmentId");
|
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||||
const status = c.req.query("status");
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
const offset = parseInt(c.req.query("offset") || "0", 10);
|
|
||||||
|
|
||||||
const conditions = [];
|
|
||||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
|
||||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
|
||||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [totalResult] = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(invoices)
|
|
||||||
.where(whereClause);
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: invoices.id,
|
|
||||||
appointmentId: invoices.appointmentId,
|
|
||||||
clientId: invoices.clientId,
|
|
||||||
clientName: clients.name,
|
|
||||||
subtotalCents: invoices.subtotalCents,
|
|
||||||
taxCents: invoices.taxCents,
|
|
||||||
tipCents: invoices.tipCents,
|
|
||||||
totalCents: invoices.totalCents,
|
|
||||||
status: invoices.status,
|
|
||||||
paymentMethod: invoices.paymentMethod,
|
|
||||||
paidAt: invoices.paidAt,
|
|
||||||
notes: invoices.notes,
|
|
||||||
createdAt: invoices.createdAt,
|
|
||||||
updatedAt: invoices.updatedAt,
|
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
|
||||||
.where(whereClause)
|
|
||||||
.orderBy(invoices.createdAt)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
invoicesRouter.get(
|
||||||
|
"/",
|
||||||
|
zValidator("query", listInvoicesQuerySchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||||
|
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||||
|
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: invoices.id,
|
||||||
|
appointmentId: invoices.appointmentId,
|
||||||
|
clientId: invoices.clientId,
|
||||||
|
clientName: clients.name,
|
||||||
|
subtotalCents: invoices.subtotalCents,
|
||||||
|
taxCents: invoices.taxCents,
|
||||||
|
tipCents: invoices.tipCents,
|
||||||
|
totalCents: invoices.totalCents,
|
||||||
|
status: invoices.status,
|
||||||
|
paymentMethod: invoices.paymentMethod,
|
||||||
|
paidAt: invoices.paidAt,
|
||||||
|
notes: invoices.notes,
|
||||||
|
createdAt: invoices.createdAt,
|
||||||
|
updatedAt: invoices.updatedAt,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(invoices.createdAt)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Get single invoice with line items and tip splits
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -462,45 +462,9 @@ import {
|
|||||||
detachPaymentMethod,
|
detachPaymentMethod,
|
||||||
createSetupIntent,
|
createSetupIntent,
|
||||||
getOrCreateStripeCustomer,
|
getOrCreateStripeCustomer,
|
||||||
|
getStripeClient,
|
||||||
} from "../services/payment.js";
|
} from "../services/payment.js";
|
||||||
|
|
||||||
const payInvoiceSchema = z.object({
|
|
||||||
invoiceId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/invoices/:id/pay",
|
|
||||||
zValidator("json", payInvoiceSchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const invoiceId = c.req.param("id");
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const [invoice] = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.id, invoiceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
|
||||||
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
if (invoice.status === "draft" || invoice.status === "void") {
|
|
||||||
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
|
|
||||||
}
|
|
||||||
if (invoice.status === "paid") {
|
|
||||||
return c.json({ error: "Invoice is already paid" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
|
||||||
const result = await createPaymentIntent(invoiceId, clientId);
|
|
||||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
|
|
||||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const payMultipleSchema = z.object({
|
const payMultipleSchema = z.object({
|
||||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||||
});
|
});
|
||||||
@@ -580,19 +544,23 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
|
|
||||||
|
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||||
|
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
||||||
|
return c.json({ error: "Payment method not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Config endpoint ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
portalRouter.get("/config", (c) => {
|
|
||||||
return c.json({
|
|
||||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
// Allows the dev login selector to vend an impersonation session for a client
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
basePriceCents: z.number().int().positive(),
|
basePriceCents: z.number().int().positive(),
|
||||||
durationMinutes: z.number().int().positive(),
|
durationMinutes: z.number().int().positive().max(480),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
export const webhooksRouter = new Hono();
|
export const webhooksRouter = new Hono();
|
||||||
|
|
||||||
webhooksRouter.post("/stripe", async (c) => {
|
webhooksRouter.post("/stripe", async (c) => {
|
||||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
if (!secret) {
|
if (!webhookSecret) {
|
||||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,11 +24,14 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
return c.json({ error: "Could not read body" }, 400);
|
return c.json({ error: "Could not read body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) {
|
||||||
|
return c.json({ error: "Stripe not configured" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
let event: Stripe.Event;
|
let event: Stripe.Event;
|
||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||||
return c.json({ error: message }, 401);
|
return c.json({ error: message }, 401);
|
||||||
@@ -40,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
|
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
const invoiceIdTrimmed = invoiceId.trim();
|
||||||
const [inv] = await db
|
const [inv] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceId))
|
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!inv) continue;
|
if (!inv) continue;
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
@@ -56,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
stripePaymentIntentId: pi.id,
|
stripePaymentIntentId: pi.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
@@ -65,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
|
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
const invoiceIdTrimmed = invoiceId.trim();
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "charge.refunded") {
|
} else if (event.type === "charge.refunded") {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { getDb, clients, eq, invoices } from "@groombook/db";
|
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
||||||
|
|
||||||
let _stripe: Stripe | null | undefined;
|
let _stripe: Stripe | null | undefined;
|
||||||
|
|
||||||
function getStripeClient(): Stripe | null {
|
export function getStripeClient(): Stripe | null {
|
||||||
if (_stripe === undefined) {
|
if (_stripe === undefined) {
|
||||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!secretKey) return null;
|
if (!secretKey) return null;
|
||||||
@@ -59,8 +59,8 @@ export async function createPaymentIntent(
|
|||||||
const allInvoices = await db
|
const allInvoices = await db
|
||||||
.select({ totalCents: invoices.totalCents })
|
.select({ totalCents: invoices.totalCents })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, firstInvoiceId));
|
.where(inArray(invoices.id, invoiceIds));
|
||||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
inArray,
|
|
||||||
lt,
|
lt,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
@@ -65,66 +64,56 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
|
||||||
|
|
||||||
if (appointmentIds.length === 0) continue;
|
|
||||||
|
|
||||||
const sentAppointmentIds = new Set(
|
|
||||||
(
|
|
||||||
await db
|
|
||||||
.select({ appointmentId: reminderLogs.appointmentId })
|
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
inArray(reminderLogs.appointmentId, appointmentIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).map((r) => r.appointmentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const joinedRows = await db
|
|
||||||
.select({
|
|
||||||
appointmentId: appointments.id,
|
|
||||||
startTime: appointments.startTime,
|
|
||||||
clientId: appointments.clientId,
|
|
||||||
petId: appointments.petId,
|
|
||||||
serviceId: appointments.serviceId,
|
|
||||||
staffId: appointments.staffId,
|
|
||||||
confirmationToken: appointments.confirmationToken,
|
|
||||||
clientName: clients.name,
|
|
||||||
clientEmail: clients.email,
|
|
||||||
clientEmailOptOut: clients.emailOptOut,
|
|
||||||
petName: pets.name,
|
|
||||||
serviceName: services.name,
|
|
||||||
staffName: staff.name,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
|
||||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
|
||||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
|
||||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
gte(appointments.startTime, windowStart),
|
|
||||||
lt(appointments.startTime, windowEnd),
|
|
||||||
eq(appointments.status, "scheduled")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
|
||||||
for (const row of joinedRows) {
|
|
||||||
appointmentMap.set(row.appointmentId, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
for (const appt of upcoming) {
|
||||||
if (sentAppointmentIds.has(appt.id)) continue;
|
// Check if reminder already sent (unique constraint prevents double-send)
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: reminderLogs.id })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.appointmentId, appt.id),
|
||||||
|
eq(reminderLogs.reminderType, window.label)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const row = appointmentMap.get(appt.id);
|
if (existing.length > 0) continue; // already sent
|
||||||
if (!row) continue;
|
|
||||||
if (!row.clientEmail || row.clientEmailOptOut) continue;
|
|
||||||
if (!row.petName || !row.serviceName) continue;
|
|
||||||
|
|
||||||
|
// Fetch related records for the email
|
||||||
|
const [client] = await db
|
||||||
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, appt.clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client || !client.email || client.emailOptOut) continue;
|
||||||
|
|
||||||
|
const [pet] = await db
|
||||||
|
.select({ name: pets.name })
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.id, appt.petId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [service] = await db
|
||||||
|
.select({ name: services.name })
|
||||||
|
.from(services)
|
||||||
|
.where(eq(services.id, appt.serviceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let groomerName: string | null = null;
|
||||||
|
if (appt.staffId) {
|
||||||
|
const [groomer] = await db
|
||||||
|
.select({ name: staff.name })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, appt.staffId))
|
||||||
|
.limit(1);
|
||||||
|
groomerName = groomer?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pet || !service) continue;
|
||||||
|
|
||||||
|
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||||
|
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -136,12 +125,12 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
|
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
row.clientEmail,
|
client.email,
|
||||||
{
|
{
|
||||||
clientName: row.clientName,
|
clientName: client.name,
|
||||||
petName: row.petName,
|
petName: pet.name,
|
||||||
serviceName: row.serviceName,
|
serviceName: service.name,
|
||||||
groomerName: row.staffName ?? null,
|
groomerName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
},
|
},
|
||||||
window.hours,
|
window.hours,
|
||||||
@@ -150,6 +139,7 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
|
// Record send — ignore conflicts (race condition between instances)
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||||
@@ -182,4 +172,3 @@ export async function runSessionCleanup(): Promise<void> {
|
|||||||
.delete(session)
|
.delete(session)
|
||||||
.where(lt(session.expiresAt, now));
|
.where(lt(session.expiresAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment as any}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
|
|||||||
+1
-1
Submodule infra updated: d6c0d13d02...b667a3f005
-1
Submodule infra-repo deleted from ff42966751
Reference in New Issue
Block a user