@lcas58/esmi-api-types 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/src/app.d.ts +1 -1
  2. package/dist/src/app.js +0 -1
  3. package/dist/src/routes/events/events.handlers.d.ts +20 -0
  4. package/dist/src/routes/events/events.handlers.js +177 -0
  5. package/dist/src/routes/events/events.index.d.ts +501 -0
  6. package/dist/src/routes/events/events.index.js +9 -0
  7. package/dist/src/routes/events/events.routes.d.ts +1375 -0
  8. package/dist/src/routes/events/events.routes.js +72 -0
  9. package/dist/src/routes/events/schemas/event.schemas.d.ts +896 -0
  10. package/dist/src/routes/events/schemas/event.schemas.js +49 -0
  11. package/dist/src/routes/events/schemas/index.d.ts +1 -0
  12. package/dist/src/routes/events/schemas/index.js +1 -0
  13. package/dist/src/routes/index.route.d.ts +13 -0
  14. package/dist/src/routes/index.route.js +19 -0
  15. package/dist/src/routes/leagues/leagues.handlers.d.ts +3 -0
  16. package/dist/src/routes/leagues/leagues.handlers.js +49 -0
  17. package/dist/src/routes/leagues/leagues.index.d.ts +53 -0
  18. package/dist/src/routes/leagues/leagues.index.js +6 -0
  19. package/dist/src/routes/leagues/leagues.routes.d.ts +137 -0
  20. package/dist/src/routes/leagues/leagues.routes.js +47 -0
  21. package/dist/src/routes/marketing/marketing.handlers.d.ts +4 -0
  22. package/dist/src/routes/marketing/marketing.handlers.js +20 -0
  23. package/dist/src/routes/marketing/marketing.index.d.ts +58 -0
  24. package/dist/src/routes/marketing/marketing.index.js +7 -0
  25. package/dist/src/routes/marketing/marketing.routes.d.ts +154 -0
  26. package/dist/src/routes/marketing/marketing.routes.js +27 -0
  27. package/dist/src/routes/organizations/organizations.handlers.d.ts +74 -0
  28. package/dist/src/routes/organizations/organizations.handlers.js +485 -0
  29. package/dist/src/routes/organizations/organizations.index.d.ts +517 -0
  30. package/dist/src/routes/organizations/organizations.index.js +12 -0
  31. package/dist/src/routes/organizations/organizations.routes.d.ts +1236 -0
  32. package/dist/src/routes/organizations/organizations.routes.js +137 -0
  33. package/dist/src/routes/organizations/tasks.test.d.ts +0 -0
  34. package/dist/src/routes/organizations/tasks.test.js +181 -0
  35. package/dist/src/routes/tags/tags.handlers.d.ts +3 -0
  36. package/dist/src/routes/tags/tags.handlers.js +15 -0
  37. package/dist/src/routes/tags/tags.index.d.ts +24 -0
  38. package/dist/src/routes/tags/tags.index.js +6 -0
  39. package/dist/src/routes/tags/tags.routes.d.ts +68 -0
  40. package/dist/src/routes/tags/tags.routes.js +25 -0
  41. package/dist/src/shared/client-types.d.ts +8 -3
  42. package/dist/src/shared/client-types.js +1 -1
  43. package/package.json +2 -2
@@ -0,0 +1,74 @@
1
+ import type { AppRouteHandler } from "../../lib/types.js";
2
+ import type { CheckOrganizationNameRoute, CheckOwnershipRoute, CreateRoute, GetOneRoute, ListRoute, PatchRoute, RemoveRoute } from "./organizations.routes.js";
3
+ /**
4
+ * Retrieves a single organization by ID
5
+ *
6
+ * @description Fetches an organization with all related data (tags, websites, social media).
7
+ * Only the organization owner can access the full details.
8
+ *
9
+ * @param c - Hono context containing request data and environment
10
+ * @returns Organization data with related entities or error response
11
+ */
12
+ export declare const getOne: AppRouteHandler<GetOneRoute>;
13
+ /**
14
+ * Lists organizations with optional filtering
15
+ *
16
+ * @description Retrieves a list of organizations with support for filtering by:
17
+ * - ownerId: Filter by organization owner
18
+ * - city, state, country: Filter by location (case-insensitive partial match)
19
+ *
20
+ * @param c - Hono context containing query parameters
21
+ * @returns Array of organizations with related data
22
+ */
23
+ export declare const list: AppRouteHandler<ListRoute>;
24
+ /**
25
+ * Creates a new organization with related data
26
+ *
27
+ * @description Creates an organization along with optional tags, websites, and social media.
28
+ * Uses a database transaction to ensure data consistency.
29
+ *
30
+ * @param c - Hono context containing organization data and authenticated user
31
+ * @returns Created organization data or error response
32
+ */
33
+ export declare const create: AppRouteHandler<CreateRoute>;
34
+ /**
35
+ * Updates an existing organization and its related data
36
+ *
37
+ * @description Performs partial updates to an organization including related entities.
38
+ * Uses replace strategy for tags, websites, and social media (delete all, insert new).
39
+ *
40
+ * @param c - Hono context containing organization ID and update data
41
+ * @returns Updated organization data or error response
42
+ */
43
+ export declare const patch: AppRouteHandler<PatchRoute>;
44
+ /**
45
+ * Deletes an organization and all related data
46
+ *
47
+ * @description Removes an organization from the database. Due to CASCADE constraints,
48
+ * all related data (websites, social media, tags) will be automatically deleted.
49
+ *
50
+ * @param c - Hono context containing organization ID
51
+ * @returns 204 No Content on success or 404 Not Found if organization doesn't exist
52
+ */
53
+ export declare const remove: AppRouteHandler<RemoveRoute>;
54
+ /**
55
+ * Checks if the authenticated user owns the specified organization
56
+ *
57
+ * @description Verifies ownership without returning organization data.
58
+ * Used for authorization checks in the frontend.
59
+ *
60
+ * @param c - Hono context containing organization ID and authenticated user
61
+ * @returns Ownership status (true/false) or 404 if organization doesn't exist
62
+ */
63
+ export declare const checkOwnership: AppRouteHandler<CheckOwnershipRoute>;
64
+ /**
65
+ * Checks if an organization name is available
66
+ *
67
+ * @description Verifies if the provided name is unique in the database.
68
+ * When taken, returns similar available slugs using pg_trgm similarity.
69
+ * Used for name validation in the frontend.
70
+ *
71
+ * @param c - Hono context containing organization name
72
+ * @returns Availability status and suggestions if taken
73
+ */
74
+ export declare const checkOrganizationName: AppRouteHandler<CheckOrganizationNameRoute>;
@@ -0,0 +1,485 @@
1
+ import { and, eq, ilike, sql } from "drizzle-orm";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as HttpStatusCodes from "stoker/http-status-codes";
4
+ import * as HttpStatusPhrases from "stoker/http-status-phrases";
5
+ import { closePool, createDb, createPool } from "../../db/index.js";
6
+ import { generateSocialMediaUrl, organizationSocialMedia, organizationTags, organizationWebsites } from "../../db/schema/index.js";
7
+ import organization from "../../db/schema/organization.js";
8
+ import { processAvatarUpdate } from "../../lib/avatar-helpers.js";
9
+ import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "../../lib/constants.js";
10
+ import { slugify } from "../../lib/helpers.js";
11
+ // =============================================================================
12
+ // HELPER FUNCTIONS
13
+ // =============================================================================
14
+ /**
15
+ * Transforms raw organization data from database to API response format
16
+ *
17
+ * @param organizationData - Raw organization data from database query
18
+ * @returns Transformed organization data for API response
19
+ */
20
+ function transformOrganizationData(organizationData) {
21
+ return {
22
+ ...organizationData,
23
+ tags: organizationData.tags.map((t) => ({
24
+ id: t.tagId,
25
+ name: t.tag.name,
26
+ })),
27
+ websites: organizationData.websites.map((w) => ({
28
+ id: w.id,
29
+ url: w.url,
30
+ label: w.label || "",
31
+ })),
32
+ socialMedia: organizationData.socialMedia.map((s) => ({
33
+ id: s.id,
34
+ platform: s.platform,
35
+ url: s.url || "",
36
+ handle: s.handle,
37
+ })),
38
+ };
39
+ }
40
+ /**
41
+ * Builds location-based filters for organization queries
42
+ *
43
+ * @param city - Optional city filter (case-insensitive partial match)
44
+ * @param state - Optional state filter (case-insensitive partial match)
45
+ * @param country - Optional country filter (case-insensitive partial match)
46
+ * @returns Array of SQL filter conditions
47
+ */
48
+ function buildLocationFilters(city, state, country) {
49
+ const filters = [];
50
+ const trimmedCity = city?.trim();
51
+ const trimmedState = state?.trim();
52
+ const trimmedCountry = country?.trim();
53
+ if (trimmedCity)
54
+ filters.push(ilike(organization.city, trimmedCity));
55
+ if (trimmedState)
56
+ filters.push(ilike(organization.state, trimmedState));
57
+ if (trimmedCountry)
58
+ filters.push(ilike(organization.country, trimmedCountry));
59
+ return filters;
60
+ }
61
+ /**
62
+ * Creates a where clause from an array of SQL conditions
63
+ *
64
+ * @param filters - Array of SQL filter conditions
65
+ * @returns Combined where clause or undefined if no filters
66
+ */
67
+ function createWhereClause(filters) {
68
+ if (filters.length === 0)
69
+ return undefined;
70
+ if (filters.length === 1)
71
+ return filters[0];
72
+ return and(...filters);
73
+ }
74
+ /**
75
+ * Validates and trims location fields from update data
76
+ *
77
+ * @param updateData - Object containing update fields
78
+ * @returns Processed update data with trimmed location fields
79
+ */
80
+ function processLocationUpdates(updateData) {
81
+ if (typeof updateData.city === "string")
82
+ updateData.city = updateData.city.trim();
83
+ if (typeof updateData.state === "string")
84
+ updateData.state = updateData.state.trim() || null;
85
+ if (typeof updateData.shortStateCd === "string")
86
+ updateData.shortStateCd = updateData.shortStateCd.trim() || null;
87
+ if (typeof updateData.country === "string")
88
+ updateData.country = updateData.country.trim();
89
+ return updateData;
90
+ }
91
+ /**
92
+ * Creates organization tags in a database transaction
93
+ *
94
+ * @param tx - Database transaction instance
95
+ * @param organizationId - ID of the organization
96
+ * @param tags - Array of tag data to create
97
+ */
98
+ async function createOrganizationTags(tx, organizationId, tags) {
99
+ if (tags && tags.length > 0) {
100
+ await tx.insert(organizationTags).values(tags.map(tag => ({
101
+ organizationId,
102
+ tagId: tag.tagId,
103
+ })));
104
+ }
105
+ }
106
+ /**
107
+ * Creates organization websites in a database transaction
108
+ *
109
+ * @param tx - Database transaction instance
110
+ * @param organizationId - ID of the organization
111
+ * @param websites - Array of website data to create
112
+ */
113
+ async function createOrganizationWebsites(tx, organizationId, websites) {
114
+ if (websites && websites.length > 0) {
115
+ await tx.insert(organizationWebsites).values(websites.map(website => ({
116
+ ...website,
117
+ organizationId,
118
+ })));
119
+ }
120
+ }
121
+ /**
122
+ * Creates organization social media in a database transaction
123
+ *
124
+ * @param tx - Database transaction instance
125
+ * @param organizationId - ID of the organization
126
+ * @param socialMedia - Array of social media data to create
127
+ */
128
+ async function createOrganizationSocialMedia(tx, organizationId, socialMedia) {
129
+ if (socialMedia && socialMedia.length > 0) {
130
+ await tx.insert(organizationSocialMedia).values(socialMedia.map(social => ({
131
+ ...social,
132
+ organizationId,
133
+ // Generate URL dynamically if not provided
134
+ url: social.url || generateSocialMediaUrl(social.platform, social.handle),
135
+ })));
136
+ }
137
+ }
138
+ // =============================================================================
139
+ // ORGANIZATION HANDLERS
140
+ // =============================================================================
141
+ /**
142
+ * Retrieves a single organization by ID
143
+ *
144
+ * @description Fetches an organization with all related data (tags, websites, social media).
145
+ * Only the organization owner can access the full details.
146
+ *
147
+ * @param c - Hono context containing request data and environment
148
+ * @returns Organization data with related entities or error response
149
+ */
150
+ export const getOne = async (c) => {
151
+ const { db } = createDb(c.env);
152
+ const { id } = c.req.valid("param");
153
+ const authUser = c.get("user");
154
+ const organizationData = await db.query.organization.findFirst({
155
+ where: eq(organization.id, id),
156
+ with: {
157
+ tags: {
158
+ with: {
159
+ tag: true,
160
+ },
161
+ },
162
+ websites: true,
163
+ socialMedia: true,
164
+ },
165
+ });
166
+ if (!organizationData) {
167
+ return c.json({
168
+ message: HttpStatusPhrases.NOT_FOUND,
169
+ }, HttpStatusCodes.NOT_FOUND);
170
+ }
171
+ // Check if the user owns this organization
172
+ if (organizationData.ownerId !== authUser.id) {
173
+ return c.json({
174
+ message: "Unauthorized",
175
+ }, HttpStatusCodes.UNAUTHORIZED);
176
+ }
177
+ const transformedData = transformOrganizationData(organizationData);
178
+ return c.json(transformedData, HttpStatusCodes.OK);
179
+ };
180
+ /**
181
+ * Lists organizations with optional filtering
182
+ *
183
+ * @description Retrieves a list of organizations with support for filtering by:
184
+ * - ownerId: Filter by organization owner
185
+ * - city, state, country: Filter by location (case-insensitive partial match)
186
+ *
187
+ * @param c - Hono context containing query parameters
188
+ * @returns Array of organizations with related data
189
+ */
190
+ export const list = async (c) => {
191
+ const { db } = createDb(c.env);
192
+ const { ownerId, city, state, country } = c.req.valid("query");
193
+ const filters = [];
194
+ // Add owner filter if specified
195
+ if (ownerId)
196
+ filters.push(eq(organization.ownerId, ownerId));
197
+ // Add location filters
198
+ const locationFilters = buildLocationFilters(city, state, country);
199
+ filters.push(...locationFilters);
200
+ const whereClause = createWhereClause(filters);
201
+ const organizations = await db.query.organization.findMany({
202
+ where: whereClause,
203
+ with: {
204
+ tags: {
205
+ with: {
206
+ tag: true,
207
+ },
208
+ },
209
+ websites: true,
210
+ socialMedia: true,
211
+ },
212
+ });
213
+ return c.json(organizations, HttpStatusCodes.OK);
214
+ };
215
+ /**
216
+ * Creates a new organization with related data
217
+ *
218
+ * @description Creates an organization along with optional tags, websites, and social media.
219
+ * Uses a database transaction to ensure data consistency.
220
+ *
221
+ * @param c - Hono context containing organization data and authenticated user
222
+ * @returns Created organization data or error response
223
+ */
224
+ export const create = async (c) => {
225
+ const { pool, dbPool } = createPool(c.env);
226
+ const data = c.req.valid("json");
227
+ const user = c.get("user");
228
+ // Process avatar if provided
229
+ let processedAvatar;
230
+ if (data.avatar) {
231
+ // We'll generate a temporary ID for the organization to use in avatar processing
232
+ const tempOrgId = randomUUID();
233
+ const avatarResult = await processAvatarUpdate(data.avatar, tempOrgId, undefined, c.env);
234
+ if (!avatarResult.success) {
235
+ return c.json({
236
+ success: false,
237
+ error: {
238
+ issues: [
239
+ {
240
+ code: "invalid_type",
241
+ path: ["avatar"],
242
+ message: avatarResult.error || "Invalid avatar",
243
+ },
244
+ ],
245
+ name: "ZodError",
246
+ },
247
+ }, HttpStatusCodes.UNPROCESSABLE_ENTITY);
248
+ }
249
+ processedAvatar = avatarResult.avatarValue;
250
+ }
251
+ try {
252
+ // Use transaction to ensure all related data is created together
253
+ const result = await dbPool.transaction(async (tx) => {
254
+ // Create organization with trimmed location data
255
+ const [insertedOrg] = await tx.insert(organization).values({
256
+ name: data.name.trim(),
257
+ description: data.description.trim(),
258
+ ownerId: user.id,
259
+ avatar: processedAvatar,
260
+ slug: slugify(data.name.trim()),
261
+ city: data.city?.trim(),
262
+ state: data.state?.trim(),
263
+ stateCd: data.stateCd?.trim(),
264
+ country: data.country?.trim(),
265
+ }).returning();
266
+ // Create related data using helper functions
267
+ await createOrganizationTags(tx, insertedOrg.id, data.tags);
268
+ await createOrganizationWebsites(tx, insertedOrg.id, data.websites);
269
+ await createOrganizationSocialMedia(tx, insertedOrg.id, data.socialMedia);
270
+ return insertedOrg;
271
+ });
272
+ return c.json(result, HttpStatusCodes.CREATED);
273
+ }
274
+ finally {
275
+ await closePool(pool);
276
+ }
277
+ };
278
+ /**
279
+ * Updates an existing organization and its related data
280
+ *
281
+ * @description Performs partial updates to an organization including related entities.
282
+ * Uses replace strategy for tags, websites, and social media (delete all, insert new).
283
+ *
284
+ * @param c - Hono context containing organization ID and update data
285
+ * @returns Updated organization data or error response
286
+ */
287
+ export const patch = async (c) => {
288
+ const { db } = createDb(c.env);
289
+ const { id } = c.req.valid("param");
290
+ const updates = c.req.valid("json");
291
+ // Validate that updates are provided
292
+ if (Object.keys(updates).length === 0) {
293
+ return c.json({
294
+ success: false,
295
+ error: {
296
+ issues: [
297
+ {
298
+ code: ZOD_ERROR_CODES.INVALID_UPDATES,
299
+ path: [],
300
+ message: ZOD_ERROR_MESSAGES.NO_UPDATES,
301
+ },
302
+ ],
303
+ name: "ZodError",
304
+ },
305
+ }, HttpStatusCodes.UNPROCESSABLE_ENTITY);
306
+ }
307
+ const { tags, websites, socialMedia, avatar, ...rest } = updates;
308
+ // If avatar is being updated, fetch current org to get old avatar URL
309
+ let processedAvatar;
310
+ if (avatar !== undefined) {
311
+ const currentOrg = await db.query.organization.findFirst({
312
+ where: eq(organization.id, id),
313
+ columns: {
314
+ avatar: true,
315
+ },
316
+ });
317
+ if (!currentOrg) {
318
+ return c.json({
319
+ message: HttpStatusPhrases.NOT_FOUND,
320
+ }, HttpStatusCodes.NOT_FOUND);
321
+ }
322
+ // Handle avatar removal (null means remove)
323
+ if (avatar === null) {
324
+ // Delete old R2 image if exists
325
+ if (currentOrg.avatar) {
326
+ const { deleteFromR2 } = await import("../../lib/avatar-helpers.js");
327
+ await deleteFromR2(currentOrg.avatar, c.env);
328
+ }
329
+ processedAvatar = null;
330
+ }
331
+ else {
332
+ // Process avatar upload/update
333
+ const avatarResult = await processAvatarUpdate(avatar, id, currentOrg.avatar ?? undefined, c.env);
334
+ if (!avatarResult.success) {
335
+ return c.json({
336
+ success: false,
337
+ error: {
338
+ issues: [
339
+ {
340
+ code: "invalid_type",
341
+ path: ["avatar"],
342
+ message: avatarResult.error || "Invalid avatar",
343
+ },
344
+ ],
345
+ name: "ZodError",
346
+ },
347
+ }, HttpStatusCodes.UNPROCESSABLE_ENTITY);
348
+ }
349
+ processedAvatar = avatarResult.avatarValue;
350
+ }
351
+ }
352
+ const updateData = processLocationUpdates({
353
+ ...rest,
354
+ slug: slugify(rest.name?.trim() || ""),
355
+ ...(processedAvatar !== undefined && { avatar: processedAvatar }),
356
+ });
357
+ const [updatedOrganization] = await db.update(organization)
358
+ .set(updateData)
359
+ .where(eq(organization.id, id))
360
+ .returning();
361
+ if (!updatedOrganization) {
362
+ return c.json({
363
+ message: HttpStatusPhrases.NOT_FOUND,
364
+ }, HttpStatusCodes.NOT_FOUND);
365
+ }
366
+ // Handle related data updates using replace strategy
367
+ if (tags !== undefined) {
368
+ await db.delete(organizationTags)
369
+ .where(eq(organizationTags.organizationId, id));
370
+ await createOrganizationTags(db, id, tags);
371
+ }
372
+ if (websites !== undefined) {
373
+ await db.delete(organizationWebsites)
374
+ .where(eq(organizationWebsites.organizationId, id));
375
+ await createOrganizationWebsites(db, id, websites);
376
+ }
377
+ if (socialMedia !== undefined) {
378
+ await db.delete(organizationSocialMedia)
379
+ .where(eq(organizationSocialMedia.organizationId, id));
380
+ await createOrganizationSocialMedia(db, id, socialMedia);
381
+ }
382
+ return c.json(updatedOrganization, HttpStatusCodes.OK);
383
+ };
384
+ /**
385
+ * Deletes an organization and all related data
386
+ *
387
+ * @description Removes an organization from the database. Due to CASCADE constraints,
388
+ * all related data (websites, social media, tags) will be automatically deleted.
389
+ *
390
+ * @param c - Hono context containing organization ID
391
+ * @returns 204 No Content on success or 404 Not Found if organization doesn't exist
392
+ */
393
+ export const remove = async (c) => {
394
+ const { db } = createDb(c.env);
395
+ const { id } = c.req.valid("param");
396
+ const result = await db.delete(organization).where(eq(organization.id, id));
397
+ if (result.rowCount === 0) {
398
+ return c.json({
399
+ message: HttpStatusPhrases.NOT_FOUND,
400
+ }, HttpStatusCodes.NOT_FOUND);
401
+ }
402
+ return c.body(null, HttpStatusCodes.NO_CONTENT);
403
+ };
404
+ /**
405
+ * Checks if the authenticated user owns the specified organization
406
+ *
407
+ * @description Verifies ownership without returning organization data.
408
+ * Used for authorization checks in the frontend.
409
+ *
410
+ * @param c - Hono context containing organization ID and authenticated user
411
+ * @returns Ownership status (true/false) or 404 if organization doesn't exist
412
+ */
413
+ export const checkOwnership = async (c) => {
414
+ const { db } = createDb(c.env);
415
+ const { id } = c.req.valid("param");
416
+ const authUser = c.get("user");
417
+ const organizationData = await db.query.organization.findFirst({
418
+ where: eq(organization.id, id),
419
+ columns: {
420
+ id: true,
421
+ ownerId: true,
422
+ },
423
+ });
424
+ if (!organizationData) {
425
+ return c.json({
426
+ message: HttpStatusPhrases.NOT_FOUND,
427
+ }, HttpStatusCodes.NOT_FOUND);
428
+ }
429
+ const isOwner = organizationData.ownerId === authUser.id;
430
+ return c.json({
431
+ isOwner,
432
+ }, HttpStatusCodes.OK);
433
+ };
434
+ /**
435
+ * Checks if an organization name is available
436
+ *
437
+ * @description Verifies if the provided name is unique in the database.
438
+ * When taken, returns similar available slugs using pg_trgm similarity.
439
+ * Used for name validation in the frontend.
440
+ *
441
+ * @param c - Hono context containing organization name
442
+ * @returns Availability status and suggestions if taken
443
+ */
444
+ // Todo: Add rate limit to this route
445
+ export const checkOrganizationName = async (c) => {
446
+ const { db } = createDb(c.env);
447
+ const { name } = c.req.valid("query");
448
+ if (!name?.trim()) {
449
+ return c.json({ message: "Name is required" }, HttpStatusCodes.UNPROCESSABLE_ENTITY);
450
+ }
451
+ const slug = slugify(name.trim());
452
+ // Check if slug exists using case-insensitive comparison
453
+ const exists = await db
454
+ .select({
455
+ id: organization.id,
456
+ slug: organization.slug,
457
+ })
458
+ .from(organization)
459
+ .where(sql `LOWER(TRIM(${organization.slug})) = ${slug.toLowerCase()}`)
460
+ .limit(1);
461
+ if (exists.length === 0) {
462
+ return c.json({ exists: false }, HttpStatusCodes.OK);
463
+ }
464
+ // Quick predefined suggestions
465
+ const candidates = [
466
+ `${slug}-org`,
467
+ `${slug}-team`,
468
+ `${slug}-club`,
469
+ `${slug}-league`,
470
+ `${slug}-hq`,
471
+ ];
472
+ // Get first 3 that don't exist
473
+ const available = await db.execute(sql `
474
+ SELECT s.candidate_slug AS slug
475
+ FROM unnest(ARRAY[${sql.join(candidates.map(c => sql `${c}`), sql `, `)}]) AS s(candidate_slug)
476
+ WHERE NOT EXISTS (
477
+ SELECT 1 FROM organization o WHERE o.slug = s.candidate_slug
478
+ )
479
+ LIMIT 3
480
+ `);
481
+ return c.json({
482
+ exists: true,
483
+ suggestions: available.rows.map(r => r.slug),
484
+ }, HttpStatusCodes.OK);
485
+ };