Skip to content

Commit d50343f

Browse files
authored
Merge pull request #286 from GTBitsOfGood/alex/278-shift-registration
Fix shift tenant isolation and RSVP registration constraints
2 parents 9c37fcb + 496ee32 commit d50343f

14 files changed

Lines changed: 4789 additions & 960 deletions

File tree

‎api/events.ts‎

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { auth } from "@/lib/auth";
66
import { EventService } from "@/lib/services/EventService";
77
import { MembersService } from "@/lib/services/MemberService";
88
import { ShiftService } from "@/lib/services/ShiftService";
9+
import { requireMembership } from "@/lib/authUtils";
10+
import { shiftErrorResponse } from "@/api/shifts/errors";
911
import { TagService } from "@/lib/services/TagService";
1012
import { paginationQuerySchema } from "../lib/apiUtils";
1113
import { ForbiddenError } from "@/lib/errors";
@@ -215,44 +217,31 @@ const app = new Hono()
215217
})
216218
.get(
217219
"/:eventId/shifts",
220+
zValidator("param", z.object({ eventId: z.string().min(1) })),
218221
zValidator("query", paginationQuerySchema),
219222
async (c) => {
220-
const { eventId } = c.req.param();
221-
const resolved = await getViewer(c);
222-
if (!resolved) {
223-
return c.json({ error: "Unauthorized" }, { status: 401 });
224-
}
223+
try {
224+
const { eventId } = c.req.valid("param");
225+
const { session, user } = await requireMembership(c);
226+
const { page, pageSize } = c.req.valid("query");
227+
const data = await ShiftService.listByEvent(
228+
eventId,
229+
session.activeOrganizationId!,
230+
{
231+
limit: pageSize,
232+
offset: (page - 1) * pageSize,
233+
},
234+
user.id,
235+
);
225236

226-
const { viewer } = resolved;
227-
const activeOrganizationId = viewer.organizationId;
228-
if (!activeOrganizationId) {
229-
return c.json({ error: "Event not found" }, { status: 404 });
237+
return c.json({
238+
data,
239+
page,
240+
pageSize,
241+
});
242+
} catch (error) {
243+
return shiftErrorResponse(c, error);
230244
}
231-
232-
const event = await EventService.findById(eventId);
233-
if (
234-
!event ||
235-
event.organizationId !== activeOrganizationId ||
236-
!canViewEvent(event, viewer)
237-
) {
238-
return c.json({ error: "Event not found" }, { status: 404 });
239-
}
240-
241-
const { page, pageSize } = c.req.valid("query");
242-
const data = await ShiftService.listByEvent(
243-
eventId,
244-
activeOrganizationId,
245-
{
246-
limit: pageSize,
247-
offset: (page - 1) * pageSize,
248-
},
249-
);
250-
251-
return c.json({
252-
data,
253-
page,
254-
pageSize,
255-
});
256245
},
257246
)
258247
.patch("/:eventId", zValidator("json", eventUpdateSchema), async (c) => {

‎api/shifts.ts‎

Lines changed: 58 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -1,216 +1,74 @@
11
import { Hono } from "hono";
2-
import { z } from "zod";
3-
import { auth } from "@/lib/auth";
2+
import { zValidator } from "@hono/zod-validator";
3+
import { requireAdmin, requireMembership } from "@/lib/authUtils";
44
import { ShiftService } from "@/lib/services/ShiftService";
5-
import { MembersService } from "@/lib/services/MemberService";
6-
import { EventService } from "@/lib/services/EventService";
5+
import {
6+
createShiftSchema,
7+
updateShiftSchema,
8+
shiftParamSchema,
9+
} from "@/lib/shifts";
710
import shiftRsvpApp from "@/api/shifts/rsvps";
8-
9-
const createShiftSchema = z.object({
10-
eventId: z.string().min(1, "eventId is required"),
11-
name: z.string().optional(),
12-
description: z.string().optional(),
13-
startTimestamp: z.string().optional(),
14-
duration: z.string().optional(),
15-
rsvpLimit: z.number().optional(),
16-
});
17-
18-
const updateShiftSchema = z.object({
19-
name: z.string().optional(),
20-
description: z.string().optional(),
21-
startTimestamp: z.string().optional(),
22-
duration: z.string().optional(),
23-
rsvpLimit: z.number().optional(),
24-
eventId: z.string().optional(),
25-
});
11+
import { shiftErrorResponse } from "@/api/shifts/errors";
2612

2713
const app = new Hono()
28-
.post("/", async (c) => {
29-
const session = await auth.api.getSession({
30-
headers: c.req.header(),
31-
});
32-
33-
if (!session?.user) {
34-
return c.json({ error: "Unauthorized" }, { status: 401 });
35-
}
36-
37-
const organizationId = session.session.activeOrganizationId;
38-
if (!organizationId) {
39-
return c.json({ error: "No active organization" }, { status: 400 });
40-
}
41-
42-
const membership = await MembersService.findByUserAndOrganization(
43-
session.user.id,
44-
organizationId,
45-
);
46-
47-
if (!MembersService.isAdminOrOwner(membership?.role)) {
48-
return c.json({ error: "Forbidden" }, { status: 403 });
49-
}
50-
51-
let body: unknown = {};
14+
.post("/", zValidator("json", createShiftSchema), async (c) => {
5215
try {
53-
body = await c.req.json();
54-
} catch {
55-
body = {};
56-
}
57-
58-
const parsed = createShiftSchema.safeParse(body);
59-
if (!parsed.success) {
60-
return c.json({ error: "Invalid request body" }, { status: 400 });
61-
}
62-
63-
const data = parsed.data;
64-
const event = await EventService.findById(data.eventId);
65-
if (!event || event.organizationId !== organizationId) {
66-
return c.json({ error: "Event not found" }, { status: 404 });
16+
const { session, user } = await requireAdmin(c);
17+
const shift = await ShiftService.createShift(
18+
session.activeOrganizationId!,
19+
user.id,
20+
c.req.valid("json"),
21+
);
22+
return c.json({ status: 200, shift });
23+
} catch (error) {
24+
return shiftErrorResponse(c, error);
6725
}
68-
69-
const startTimestamp =
70-
data.startTimestamp && !isNaN(new Date(data.startTimestamp).getTime())
71-
? new Date(data.startTimestamp)
72-
: new Date();
73-
74-
const shift = await ShiftService.createShift({
75-
organizationId,
76-
eventId: data.eventId,
77-
name: data.name || "",
78-
description: data.description || "",
79-
startTimestamp,
80-
duration: data.duration || "",
81-
rsvpLimit: data.rsvpLimit || 0,
82-
});
83-
84-
return c.json({ status: 200, shift });
8526
})
86-
.patch("/:shiftId", async (c) => {
87-
const session = await auth.api.getSession({
88-
headers: c.req.header(),
89-
});
90-
91-
if (!session?.user) {
92-
return c.json({ error: "Unauthorized" }, { status: 401 });
93-
}
94-
95-
const organizationId = session.session.activeOrganizationId;
96-
if (!organizationId) {
97-
return c.json({ error: "No active organization" }, { status: 400 });
98-
}
99-
100-
const membership = await MembersService.findByUserAndOrganization(
101-
session.user.id,
102-
organizationId,
103-
);
104-
105-
if (!MembersService.isAdminOrOwner(membership?.role)) {
106-
return c.json({ error: "Forbidden" }, { status: 403 });
107-
}
108-
109-
const { shiftId } = c.req.param();
110-
let body: unknown = {};
111-
try {
112-
body = await c.req.json();
113-
} catch {
114-
body = {};
115-
}
116-
117-
const parsed = updateShiftSchema.safeParse(body);
118-
if (!parsed.success) {
119-
return c.json({ error: "Invalid request body" }, { status: 400 });
120-
}
121-
122-
const data = parsed.data;
123-
124-
if (Object.keys(data).length === 0) {
125-
return c.json({ error: "No fields to update" }, { status: 400 });
126-
}
127-
128-
if (data.eventId) {
129-
const event = await EventService.findById(data.eventId);
130-
if (!event || event.organizationId !== organizationId) {
131-
return c.json({ error: "Event not found" }, { status: 404 });
27+
.patch(
28+
"/:shiftId",
29+
zValidator("param", shiftParamSchema),
30+
zValidator("json", updateShiftSchema),
31+
async (c) => {
32+
try {
33+
const { session, user } = await requireAdmin(c);
34+
const shift = await ShiftService.updateShift(
35+
c.req.valid("param").shiftId,
36+
session.activeOrganizationId!,
37+
user.id,
38+
c.req.valid("json"),
39+
);
40+
return c.json({ success: true, shift });
41+
} catch (error) {
42+
return shiftErrorResponse(c, error);
13243
}
133-
}
134-
135-
const updates = Object.fromEntries(
136-
Object.entries({
137-
...data,
138-
startTimestamp:
139-
data.startTimestamp !== undefined
140-
? new Date(data.startTimestamp)
141-
: undefined,
142-
}).filter(([, value]) => value !== undefined),
143-
) as {
144-
name?: string;
145-
description?: string;
146-
startTimestamp?: Date;
147-
duration?: string;
148-
rsvpLimit?: number;
149-
eventId?: string;
150-
};
151-
152-
await ShiftService.updateShift(shiftId, updates);
153-
const shift = await ShiftService.findById(shiftId);
154-
155-
return c.json({ success: true, shift });
156-
})
157-
.get("/:shiftId", async (c) => {
158-
const session = await auth.api.getSession({
159-
headers: c.req.header(),
160-
});
161-
162-
if (!session?.user) {
163-
return c.json({ error: "Unauthorized" }, { status: 401 });
164-
}
165-
166-
const organizationId = session.session.activeOrganizationId;
167-
if (!organizationId) {
168-
return c.json({ error: "No active organization" }, { status: 400 });
169-
}
170-
171-
const { shiftId } = c.req.param();
172-
const shift = await ShiftService.findById(shiftId);
173-
174-
if (!shift || shift.organizationId !== organizationId) {
175-
return c.json(
176-
{ error: "Shift not found or unauthorized" },
177-
{ status: 404 },
44+
},
45+
)
46+
.get("/:shiftId", zValidator("param", shiftParamSchema), async (c) => {
47+
try {
48+
const { session, user } = await requireMembership(c);
49+
const shift = await ShiftService.findById(
50+
c.req.valid("param").shiftId,
51+
session.activeOrganizationId!,
52+
user.id,
17853
);
54+
if (!shift) return c.json({ error: "Shift not found" }, 404);
55+
return c.json({ status: 200, shift });
56+
} catch (error) {
57+
return shiftErrorResponse(c, error);
17958
}
180-
181-
return c.json({ status: 200, shift });
18259
})
183-
.delete("/:shiftId", async (c) => {
184-
const session = await auth.api.getSession({
185-
headers: c.req.header(),
186-
});
187-
188-
if (!session?.user) {
189-
return c.json({ error: "Unauthorized" }, { status: 401 });
190-
}
191-
192-
const organizationId = session.session.activeOrganizationId;
193-
if (!organizationId) {
194-
return c.json({ error: "No active organization" }, { status: 400 });
195-
}
196-
197-
const membership = await MembersService.findByUserAndOrganization(
198-
session.user.id,
199-
organizationId,
200-
);
201-
202-
if (!MembersService.isAdminOrOwner(membership?.role)) {
203-
return c.json({ error: "Forbidden" }, { status: 403 });
204-
}
205-
206-
const { shiftId } = c.req.param();
207-
const shift = await ShiftService.deleteById(shiftId);
208-
209-
if (!shift) {
210-
return c.json({ error: "Shift not found" }, { status: 404 });
60+
.delete("/:shiftId", zValidator("param", shiftParamSchema), async (c) => {
61+
try {
62+
const { session, user } = await requireAdmin(c);
63+
const shift = await ShiftService.deleteById(
64+
c.req.valid("param").shiftId,
65+
session.activeOrganizationId!,
66+
user.id,
67+
);
68+
return c.json({ success: true, shift });
69+
} catch (error) {
70+
return shiftErrorResponse(c, error);
21171
}
212-
213-
return c.json({ success: true, shift });
21472
})
21573
.route("", shiftRsvpApp);
21674

‎api/shifts/errors.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Context } from "hono";
2+
import { HTTPException } from "hono/http-exception";
3+
import { ZodError } from "zod";
4+
import { ShiftError } from "@/lib/shifts";
5+
6+
export function shiftErrorResponse(c: Context, error: unknown) {
7+
if (error instanceof ShiftError) {
8+
const status =
9+
error.reason === "forbidden"
10+
? 403
11+
: error.reason === "not-visible" || error.reason === "not-a-member"
12+
? 404
13+
: error.reason === "full" ||
14+
error.reason === "capacity-too-small" ||
15+
error.reason === "shift-changed"
16+
? 409
17+
: 400;
18+
return c.json({ error: error.message, reason: error.reason }, status);
19+
}
20+
if (error instanceof ZodError) {
21+
return c.json({ error: "Invalid request", issues: error.issues }, 400);
22+
}
23+
if (error instanceof HTTPException) {
24+
if (error.status === 400 || error.status === 401 || error.status === 403) {
25+
return c.json({ error: error.message }, error.status);
26+
}
27+
}
28+
throw error;
29+
}

0 commit comments

Comments
 (0)