Skip to content

Commit bd37319

Browse files
authored
feat: add separate endpoints to retrieve lists of slugs (#116)
this adds separate endpoints to retrieve lists of slugs. these endpoints can be used in `generateStaticParams` in next.js apps, where only the slugs are needed, not the full entity objects. note that `/events/slugs` matches before `/events/:id` so we can never have an event slug named "slugs".
1 parent 3ee94c5 commit bd37319

File tree

25 files changed

+985
-27
lines changed

25 files changed

+985
-27
lines changed

apps/api-server/src/routes/events/index.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { createRouter } from "@/lib/factory";
55
import { resolver } from "@/lib/openapi/resolver";
66
import { BAD_REQUEST, NOT_FOUND } from "@/lib/openapi/responses";
77
import { validate, validator } from "@/lib/openapi/validator";
8-
import { GetEventById, GetEventBySlug, GetEvents } from "@/routes/events/schemas";
9-
import { getEventById, getEventBySlug, getEvents } from "@/routes/events/service";
8+
import { GetEventById, GetEventBySlug, GetEvents, GetEventSlugs } from "@/routes/events/schemas";
9+
import { getEventById, getEventBySlug, getEvents, getEventSlugs } from "@/routes/events/service";
1010

1111
export const router = createRouter()
1212
/**
@@ -46,6 +46,43 @@ export const router = createRouter()
4646
},
4747
)
4848

49+
/**
50+
* GET /api/events/slugs
51+
*/
52+
.get(
53+
"/slugs",
54+
describeRoute({
55+
tags: ["events"],
56+
summary: "Get event slugs",
57+
description: "Retrieve a paginated list of event slugs",
58+
operationId: "getEventSlugs",
59+
responses: {
60+
200: {
61+
description: "Success response",
62+
content: {
63+
"application/json": {
64+
schema: resolver(GetEventSlugs.ResponseSchema),
65+
},
66+
},
67+
},
68+
...BAD_REQUEST,
69+
},
70+
}),
71+
validator("query", GetEventSlugs.QuerySchema),
72+
async (c) => {
73+
const { limit, offset } = c.req.valid("query");
74+
75+
const db = c.get("db");
76+
assert(db, "Database must be provided via middleware.");
77+
78+
const data = await getEventSlugs(db, { limit, offset });
79+
80+
const payload = await validate(GetEventSlugs.ResponseSchema, data);
81+
82+
return c.json(payload);
83+
},
84+
)
85+
4986
/**
5087
* GET /api/events/:id
5188
*/

apps/api-server/src/routes/events/schemas.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ export const EventSchema = v.pipe(
4949

5050
export type Event = v.InferOutput<typeof EventSchema>;
5151

52+
export const EventSlugSchema = v.pipe(
53+
v.object({
54+
...v.pick(schema.EventSelectSchema, ["id"]).entries,
55+
entity: v.pick(schema.EntitySelectSchema, ["slug"]),
56+
}),
57+
v.description("Event slug"),
58+
v.metadata({ ref: "EventSlug" }),
59+
);
60+
61+
export type EventSlug = v.InferOutput<typeof EventSlugSchema>;
62+
63+
export const EventSlugListSchema = v.pipe(
64+
v.array(EventSlugSchema),
65+
v.description("List of event slugs"),
66+
v.metadata({ ref: "EventSlugList" }),
67+
);
68+
69+
export type EventSlugList = v.InferOutput<typeof EventSlugListSchema>;
70+
5271
export const GetEvents = {
5372
QuerySchema: PaginationQuerySchema,
5473
ResponseSchema: v.pipe(
@@ -72,6 +91,18 @@ export const GetEventById = {
7291
ResponseSchema: EventSchema,
7392
};
7493

94+
export const GetEventSlugs = {
95+
QuerySchema: PaginationQuerySchema,
96+
ResponseSchema: v.pipe(
97+
v.object({
98+
...PaginatedResponseSchema.entries,
99+
data: EventSlugListSchema,
100+
}),
101+
v.description("Paginated list of event slugs"),
102+
v.metadata({ ref: "GetEventSlugsResponse" }),
103+
),
104+
};
105+
75106
export const GetEventBySlug = {
76107
ParamsSchema: v.pipe(
77108
v.object({

apps/api-server/src/routes/events/service.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,64 @@ export async function getEventById(db: Database | Transaction, params: GetEventB
131131

132132
//
133133

134+
interface GetEventSlugsParams {
135+
/** @default 10 */
136+
limit?: number;
137+
/** @default 0 */
138+
offset?: number;
139+
}
140+
141+
export async function getEventSlugs(db: Database | Transaction, params: GetEventSlugsParams) {
142+
const { limit = 10, offset = 0 } = params;
143+
144+
const [items, aggregate] = await Promise.all([
145+
db.query.events.findMany({
146+
where: {
147+
entity: {
148+
status: {
149+
type: "published",
150+
},
151+
},
152+
},
153+
columns: {
154+
id: true,
155+
},
156+
with: {
157+
entity: {
158+
columns: {
159+
slug: true,
160+
updatedAt: true,
161+
},
162+
},
163+
image: {
164+
columns: {
165+
key: true,
166+
},
167+
},
168+
},
169+
orderBy(t, { desc, sql }) {
170+
return [desc(sql`"entity"."r" ->> 'updatedAt'`)];
171+
},
172+
limit,
173+
offset,
174+
}),
175+
db
176+
.select({ total: count() })
177+
.from(schema.events)
178+
.innerJoin(schema.entities, eq(schema.events.id, schema.entities.id))
179+
.innerJoin(schema.entityStatus, eq(schema.entities.statusId, schema.entityStatus.id))
180+
.where(eq(schema.entityStatus.type, "published")),
181+
]);
182+
183+
const total = aggregate.at(0)?.total ?? 0;
184+
185+
const data = items;
186+
187+
return { data, limit, offset, total };
188+
}
189+
190+
//
191+
134192
interface GetEventBySlugParams {
135193
slug: schema.Entity["slug"];
136194
}

apps/api-server/src/routes/impact-case-studies/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import {
99
GetImpactCaseStudies,
1010
GetImpactCaseStudyById,
1111
GetImpactCaseStudyBySlug,
12+
GetImpactCaseStudySlugs,
1213
} from "@/routes/impact-case-studies/schemas";
1314
import {
1415
getImpactCaseStudies,
1516
getImpactCaseStudyById,
1617
getImpactCaseStudyBySlug,
18+
getImpactCaseStudySlugs,
1719
} from "@/routes/impact-case-studies/service";
1820

1921
export const router = createRouter()
@@ -54,6 +56,43 @@ export const router = createRouter()
5456
},
5557
)
5658

59+
/**
60+
* GET /api/impact-case-studies/slugs
61+
*/
62+
.get(
63+
"/slugs",
64+
describeRoute({
65+
tags: ["impact-case-studies"],
66+
summary: "Get impact case study slugs",
67+
description: "Retrieve a paginated list of impact case study slugs",
68+
operationId: "getImpactCaseStudySlugs",
69+
responses: {
70+
200: {
71+
description: "Success response",
72+
content: {
73+
"application/json": {
74+
schema: resolver(GetImpactCaseStudySlugs.ResponseSchema),
75+
},
76+
},
77+
},
78+
...BAD_REQUEST,
79+
},
80+
}),
81+
validator("query", GetImpactCaseStudySlugs.QuerySchema),
82+
async (c) => {
83+
const { limit, offset } = c.req.valid("query");
84+
85+
const db = c.get("db");
86+
assert(db, "Database must be provided via middleware.");
87+
88+
const data = await getImpactCaseStudySlugs(db, { limit, offset });
89+
90+
const payload = await validate(GetImpactCaseStudySlugs.ResponseSchema, data);
91+
92+
return c.json(payload);
93+
},
94+
)
95+
5796
/**
5897
* GET /api/impact-case-studies/:id
5998
*/

apps/api-server/src/routes/impact-case-studies/schemas.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ export const ImpactCaseStudySchema = v.pipe(
4141

4242
export type ImpactCaseStudy = v.InferOutput<typeof ImpactCaseStudySchema>;
4343

44+
export const ImpactCaseStudySlugSchema = v.pipe(
45+
v.object({
46+
...v.pick(schema.ImpactCaseStudySelectSchema, ["id"]).entries,
47+
entity: v.pick(schema.EntitySelectSchema, ["slug"]),
48+
}),
49+
v.description("Impact case study slug"),
50+
v.metadata({ ref: "ImpactCaseStudySlug" }),
51+
);
52+
53+
export type ImpactCaseStudySlug = v.InferOutput<typeof ImpactCaseStudySlugSchema>;
54+
55+
export const ImpactCaseStudySlugListSchema = v.pipe(
56+
v.array(ImpactCaseStudySlugSchema),
57+
v.description("List of impact case study slugs"),
58+
v.metadata({ ref: "ImpactCaseStudySlugList" }),
59+
);
60+
61+
export type ImpactCaseStudySlugList = v.InferOutput<typeof ImpactCaseStudySlugListSchema>;
62+
4463
export const GetImpactCaseStudies = {
4564
QuerySchema: PaginationQuerySchema,
4665
ResponseSchema: v.pipe(
@@ -64,6 +83,18 @@ export const GetImpactCaseStudyById = {
6483
ResponseSchema: ImpactCaseStudySchema,
6584
};
6685

86+
export const GetImpactCaseStudySlugs = {
87+
QuerySchema: PaginationQuerySchema,
88+
ResponseSchema: v.pipe(
89+
v.object({
90+
...PaginatedResponseSchema.entries,
91+
data: ImpactCaseStudySlugListSchema,
92+
}),
93+
v.description("Paginated list of impact case study slugs"),
94+
v.metadata({ ref: "GetImpactCaseStudySlugsResponse" }),
95+
),
96+
};
97+
6798
export const GetImpactCaseStudyBySlug = {
6899
ParamsSchema: v.pipe(
69100
v.object({

apps/api-server/src/routes/impact-case-studies/service.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,67 @@ export async function getImpactCaseStudyById(
153153

154154
//
155155

156+
interface GetImpactCaseStudySlugsParams {
157+
/** @default 10 */
158+
limit?: number;
159+
/** @default 0 */
160+
offset?: number;
161+
}
162+
163+
export async function getImpactCaseStudySlugs(
164+
db: Database | Transaction,
165+
params: GetImpactCaseStudySlugsParams,
166+
) {
167+
const { limit = 10, offset = 0 } = params;
168+
169+
const [items, aggregate] = await Promise.all([
170+
db.query.impactCaseStudies.findMany({
171+
where: {
172+
entity: {
173+
status: {
174+
type: "published",
175+
},
176+
},
177+
},
178+
columns: {
179+
id: true,
180+
},
181+
with: {
182+
entity: {
183+
columns: {
184+
slug: true,
185+
updatedAt: true,
186+
},
187+
},
188+
image: {
189+
columns: {
190+
key: true,
191+
},
192+
},
193+
},
194+
orderBy(t, { desc, sql }) {
195+
return [desc(sql`"entity"."r" ->> 'updatedAt'`)];
196+
},
197+
limit,
198+
offset,
199+
}),
200+
db
201+
.select({ total: count() })
202+
.from(schema.impactCaseStudies)
203+
.innerJoin(schema.entities, eq(schema.impactCaseStudies.id, schema.entities.id))
204+
.innerJoin(schema.entityStatus, eq(schema.entities.statusId, schema.entityStatus.id))
205+
.where(eq(schema.entityStatus.type, "published")),
206+
]);
207+
208+
const total = aggregate.at(0)?.total ?? 0;
209+
210+
const data = items;
211+
212+
return { data, limit, offset, total };
213+
}
214+
215+
//
216+
156217
interface GetImpactCaseStudyBySlugParams {
157218
slug: schema.Entity["slug"];
158219
}

apps/api-server/src/routes/members-partners/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { validate, validator } from "@/lib/openapi/validator";
88
import {
99
GetMemberOrPartnerById,
1010
GetMemberOrPartnerBySlug,
11+
GetMemberOrPartnerSlugs,
1112
GetMembersAndPartners,
1213
} from "@/routes/members-partners/schemas";
1314
import {
1415
getMemberOrPartnerById,
1516
getMemberOrPartnerBySlug,
17+
getMemberOrPartnerSlugs,
1618
getMembersAndPartners,
1719
} from "@/routes/members-partners/service";
1820

@@ -54,6 +56,43 @@ export const router = createRouter()
5456
},
5557
)
5658

59+
/**
60+
* GET /api/members-partners/slugs
61+
*/
62+
.get(
63+
"/slugs",
64+
describeRoute({
65+
tags: ["members-partners"],
66+
summary: "Get member or partner slugs",
67+
description: "Retrieve a paginated list of member or partner slugs",
68+
operationId: "getMemberOrPartnerSlugs",
69+
responses: {
70+
200: {
71+
description: "Success response",
72+
content: {
73+
"application/json": {
74+
schema: resolver(GetMemberOrPartnerSlugs.ResponseSchema),
75+
},
76+
},
77+
},
78+
...BAD_REQUEST,
79+
},
80+
}),
81+
validator("query", GetMemberOrPartnerSlugs.QuerySchema),
82+
async (c) => {
83+
const { limit, offset } = c.req.valid("query");
84+
85+
const db = c.get("db");
86+
assert(db, "Database must be provided via middleware.");
87+
88+
const data = await getMemberOrPartnerSlugs(db, { limit, offset });
89+
90+
const payload = await validate(GetMemberOrPartnerSlugs.ResponseSchema, data);
91+
92+
return c.json(payload);
93+
},
94+
)
95+
5796
/**
5897
* GET /api/members-partners/:id
5998
*/

0 commit comments

Comments
 (0)