Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d87f0f9608 | |||
| 6f9e6e7153 | |||
| dc947874ca | |||
| 78b71cca58 | |||
| 5456637705 | |||
| 3037b77fe8 | |||
| ae873215c0 | |||
| 9d37053580 | |||
| fdaf4db0d5 |
@@ -7,5 +7,3 @@ apps/web/dist
|
|||||||
apps/api/dist
|
apps/api/dist
|
||||||
packages/db/dist
|
packages/db/dist
|
||||||
packages/types/dist
|
packages/types/dist
|
||||||
.turbo
|
|
||||||
screenshots/
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -44,8 +42,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -66,8 +62,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -107,8 +101,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -246,6 +238,7 @@ jobs:
|
|||||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||||
|
|
||||||
# Run migration with PR image
|
# Run migration with PR image
|
||||||
|
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
|
||||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -310,8 +303,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -418,17 +409,11 @@ jobs:
|
|||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${TAG}"
|
git push -u origin "chore/update-image-tags-${TAG}"
|
||||||
|
|
||||||
# Check if PR already exists for this branch
|
# Create PR and merge immediately (no required checks on groombook/infra)
|
||||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
PR_URL=$(gh pr create \
|
||||||
if [ -n "$EXISTING_PR" ]; then
|
--repo groombook/infra \
|
||||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
--base main \
|
||||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
--head "chore/update-image-tags-${TAG}" \
|
||||||
else
|
--title "chore: deploy ${TAG} to dev" \
|
||||||
PR_URL=$(gh pr create \
|
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||||
--repo groombook/infra \
|
gh pr merge "$PR_URL" --merge
|
||||||
--base main \
|
|
||||||
--head "chore/update-image-tags-${TAG}" \
|
|
||||||
--title "chore: deploy ${TAG} to dev" \
|
|
||||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
|
||||||
gh pr merge "$PR_URL" --merge
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -14,29 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Validate tag format
|
|
||||||
run: |
|
|
||||||
TAG="${{ inputs.tag }}"
|
|
||||||
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
|
|
||||||
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Tag format valid: $TAG"
|
|
||||||
|
|
||||||
- name: Verify image exists in GHCR
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
TAG="${{ inputs.tag }}"
|
|
||||||
# Check that the API image exists — if API was pushed, web/migrate were too
|
|
||||||
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
|
|
||||||
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
|
|
||||||
|
|
||||||
- name: Generate infra repo token
|
- name: Generate infra repo token
|
||||||
id: infra-token
|
id: infra-token
|
||||||
uses: tibdex/github-app-token@v2
|
uses: tibdex/github-app-token@v2
|
||||||
|
|||||||
+1
-5
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
RUN mkdir -p /home/node/.cache/node/corepack
|
|
||||||
COPY packages/ packages/
|
COPY packages/ packages/
|
||||||
COPY apps/api/ apps/api/
|
COPY apps/api/ apps/api/
|
||||||
RUN pnpm --filter @groombook/types build && \
|
RUN pnpm --filter @groombook/types build && \
|
||||||
@@ -35,9 +34,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
|||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
RUN apk add --no-cache curl
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:3000/health || exit 1
|
|
||||||
CMD ["node", "apps/api/dist/index.js"]
|
CMD ["node", "apps/api/dist/index.js"]
|
||||||
|
|
||||||
# Migrate stage — runs drizzle-kit migrate against the database
|
# Migrate stage — runs drizzle-kit migrate against the database
|
||||||
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
|
|||||||
|
|
||||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||||
FROM builder AS reset
|
FROM builder AS reset
|
||||||
CMD ["pnpm", "db:reset"]
|
CMD ["pnpm", "db:reset"]
|
||||||
|
|||||||
+1
-16
@@ -187,24 +187,9 @@ api.route("/search", searchRouter);
|
|||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
const server = serve({ fetch: app.fetch, port });
|
serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
function shutdown() {
|
|
||||||
console.log("Shutting down gracefully...");
|
|
||||||
server.close(() => {
|
|
||||||
console.log("HTTP server closed");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error("Forced shutdown after timeout");
|
|
||||||
process.exit(1);
|
|
||||||
}, 10_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
export const appointmentGroupsRouter = new Hono();
|
||||||
|
|
||||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,8 +49,6 @@ 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)]
|
||||||
@@ -91,16 +88,6 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,8 +96,6 @@ 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()
|
||||||
@@ -126,7 +111,6 @@ 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,
|
||||||
@@ -141,15 +125,6 @@ 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)
|
||||||
@@ -165,13 +140,6 @@ 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);
|
||||||
|
|
||||||
@@ -276,28 +244,6 @@ 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)
|
||||||
@@ -315,8 +261,6 @@ 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 })
|
||||||
@@ -324,20 +268,6 @@ 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() })
|
||||||
|
|||||||
@@ -163,28 +163,6 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apptFields.batherStaffId) {
|
|
||||||
const bathConflicts = 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 (bathConflicts.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
|
||||||
@@ -420,8 +398,7 @@ appointmentsRouter.patch(
|
|||||||
const needsConflictCheck =
|
const needsConflictCheck =
|
||||||
updateFields.startTime !== undefined ||
|
updateFields.startTime !== undefined ||
|
||||||
updateFields.endTime !== undefined ||
|
updateFields.endTime !== undefined ||
|
||||||
updateFields.staffId !== undefined ||
|
updateFields.staffId !== undefined;
|
||||||
updateFields.batherStaffId !== undefined;
|
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
...updateFields,
|
...updateFields,
|
||||||
@@ -457,11 +434,6 @@ appointmentsRouter.patch(
|
|||||||
updateFields.staffId !== undefined
|
updateFields.staffId !== undefined
|
||||||
? updateFields.staffId
|
? updateFields.staffId
|
||||||
: current.staffId;
|
: current.staffId;
|
||||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
|
||||||
const batherStaffId =
|
|
||||||
updateFields.batherStaffId !== undefined
|
|
||||||
? updateFields.batherStaffId
|
|
||||||
: current.batherStaffId;
|
|
||||||
|
|
||||||
if (end <= start) {
|
if (end <= start) {
|
||||||
throw Object.assign(new Error("end before start"), {
|
throw Object.assign(new Error("end before start"), {
|
||||||
@@ -489,29 +461,6 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batherStaffId) {
|
|
||||||
const bathConflicts = 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 (bathConflicts.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)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
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 { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
export const groomingLogsRouter = new Hono();
|
||||||
|
|
||||||
const createLogSchema = z.object({
|
const createLogSchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -21,26 +20,6 @@ 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)
|
||||||
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
|
|||||||
zValidator("json", createLogSchema),
|
zValidator("json", createLogSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
const { groomedAt, ...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();
|
||||||
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
|
|||||||
|
|
||||||
groomingLogsRouter.delete("/:id", async (c) => {
|
groomingLogsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const [row] = await db
|
||||||
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, id))
|
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||||
.returning();
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -286,10 +286,6 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||||
|
|
||||||
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
|
||||||
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
const churnRisk = await db
|
const churnRisk = await db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.id,
|
clientId: clients.id,
|
||||||
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
.having(
|
.having(
|
||||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||||
)
|
)
|
||||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
const [churnCountRow] = await db
|
|
||||||
.select({ total: sql<number>`count(*)::int` })
|
|
||||||
.from(
|
|
||||||
db
|
|
||||||
.select({ id: clients.id })
|
|
||||||
.from(clients)
|
|
||||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
|
||||||
.groupBy(clients.id)
|
|
||||||
.having(
|
|
||||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
|
||||||
)
|
|
||||||
.as("churn_count")
|
|
||||||
);
|
|
||||||
const churnRiskTotal = churnCountRow?.total ?? 0;
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
from: from.toISOString(),
|
from: from.toISOString(),
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
newClients,
|
newClients,
|
||||||
activeInPeriodCount: activeInPeriod.length,
|
activeInPeriodCount: activeInPeriod.length,
|
||||||
churnRisk,
|
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
||||||
churnRiskTotal,
|
churnRiskTotal: churnRisk.length,
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,3 @@ FROM nginx:alpine AS runner
|
|||||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:80/ || exit 1
|
|
||||||
|
|||||||
@@ -3,22 +3,10 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy API calls to the API service
|
# Proxy API calls to the API service
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ async function seed() {
|
|||||||
|
|
||||||
// ── Staff ──
|
// ── Staff ──
|
||||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
||||||
);
|
);
|
||||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||||
|
|||||||
Reference in New Issue
Block a user