@frigear-nu/events 0.0.0-stage → 0.0.2
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.
- package/README.md +35 -2
- package/dist/module.d.mts +168 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +113 -0
- package/dist/runtime/app/components/EventCard.d.vue.ts +8 -0
- package/dist/runtime/app/components/EventCard.vue +52 -0
- package/dist/runtime/app/components/EventCard.vue.d.ts +8 -0
- package/dist/runtime/app/components/EventTicketCard.d.vue.ts +12 -0
- package/dist/runtime/app/components/EventTicketCard.vue +82 -0
- package/dist/runtime/app/components/EventTicketCard.vue.d.ts +12 -0
- package/dist/runtime/app/components/EventTicketPicker.d.vue.ts +13 -0
- package/dist/runtime/app/components/EventTicketPicker.vue +133 -0
- package/dist/runtime/app/components/EventTicketPicker.vue.d.ts +13 -0
- package/dist/runtime/app/composables/index.d.ts +1 -0
- package/dist/runtime/app/composables/index.js +1 -0
- package/dist/runtime/app/composables/useEvents.d.ts +21 -0
- package/dist/runtime/app/composables/useEvents.js +50 -0
- package/dist/runtime/app/pages/event.d.vue.ts +3 -0
- package/dist/runtime/app/pages/event.vue +135 -0
- package/dist/runtime/app/pages/event.vue.d.ts +3 -0
- package/dist/runtime/app/pages/events.d.vue.ts +3 -0
- package/dist/runtime/app/pages/events.vue +43 -0
- package/dist/runtime/app/pages/events.vue.d.ts +3 -0
- package/dist/runtime/app/pages/tickets.d.vue.ts +3 -0
- package/dist/runtime/app/pages/tickets.vue +30 -0
- package/dist/runtime/app/pages/tickets.vue.d.ts +3 -0
- package/dist/runtime/i18n/lang/da.json +87 -0
- package/dist/runtime/i18n/lang/en.json +87 -0
- package/dist/runtime/menus.d.ts +2 -0
- package/dist/runtime/menus.js +14 -0
- package/dist/runtime/server/db/schema.sqlite.d.ts +1095 -0
- package/dist/runtime/server/db/schema.sqlite.js +100 -0
- package/dist/runtime/server/plugins/payment-hooks.d.ts +2 -0
- package/dist/runtime/server/plugins/payment-hooks.js +34 -0
- package/dist/runtime/server/routes/admin/addon-group.delete.d.ts +2 -0
- package/dist/runtime/server/routes/admin/addon-group.delete.js +7 -0
- package/dist/runtime/server/routes/admin/addon-group.patch.d.ts +2 -0
- package/dist/runtime/server/routes/admin/addon-group.patch.js +11 -0
- package/dist/runtime/server/routes/admin/addon-groups.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/addon-groups.post.js +11 -0
- package/dist/runtime/server/routes/admin/addon.delete.d.ts +2 -0
- package/dist/runtime/server/routes/admin/addon.delete.js +7 -0
- package/dist/runtime/server/routes/admin/addon.patch.d.ts +2 -0
- package/dist/runtime/server/routes/admin/addon.patch.js +10 -0
- package/dist/runtime/server/routes/admin/addons.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/addons.post.js +12 -0
- package/dist/runtime/server/routes/admin/attendees.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/attendees.get.js +11 -0
- package/dist/runtime/server/routes/admin/event.delete.d.ts +2 -0
- package/dist/runtime/server/routes/admin/event.delete.js +10 -0
- package/dist/runtime/server/routes/admin/event.patch.d.ts +2 -0
- package/dist/runtime/server/routes/admin/event.patch.js +11 -0
- package/dist/runtime/server/routes/admin/events.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/events.get.js +6 -0
- package/dist/runtime/server/routes/admin/events.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/events.post.js +19 -0
- package/dist/runtime/server/routes/admin/ticket-cancel.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/ticket-cancel.post.js +14 -0
- package/dist/runtime/server/routes/admin/ticket-refund.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/ticket-refund.post.js +10 -0
- package/dist/runtime/server/routes/admin/ticket-type.delete.d.ts +2 -0
- package/dist/runtime/server/routes/admin/ticket-type.delete.js +10 -0
- package/dist/runtime/server/routes/admin/ticket-type.patch.d.ts +2 -0
- package/dist/runtime/server/routes/admin/ticket-type.patch.js +11 -0
- package/dist/runtime/server/routes/admin/ticket-types.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/ticket-types.post.js +11 -0
- package/dist/runtime/server/routes/public/check-in.post.d.ts +2 -0
- package/dist/runtime/server/routes/public/check-in.post.js +15 -0
- package/dist/runtime/server/routes/public/event.get.d.ts +2 -0
- package/dist/runtime/server/routes/public/event.get.js +7 -0
- package/dist/runtime/server/routes/public/events.get.d.ts +2 -0
- package/dist/runtime/server/routes/public/events.get.js +1 -0
- package/dist/runtime/server/routes/public/purchase.post.d.ts +2 -0
- package/dist/runtime/server/routes/public/purchase.post.js +12 -0
- package/dist/runtime/server/routes/public/ticket-pay.post.d.ts +2 -0
- package/dist/runtime/server/routes/public/ticket-pay.post.js +10 -0
- package/dist/runtime/server/routes/public/ticket.get.d.ts +2 -0
- package/dist/runtime/server/routes/public/ticket.get.js +4 -0
- package/dist/runtime/server/routes/public/tickets-me.get.d.ts +2 -0
- package/dist/runtime/server/routes/public/tickets-me.get.js +4 -0
- package/dist/runtime/server/utils/admin-schemas.d.ts +96 -0
- package/dist/runtime/server/utils/admin-schemas.js +48 -0
- package/dist/runtime/server/utils/admin.d.ts +7 -0
- package/dist/runtime/server/utils/admin.js +24 -0
- package/dist/runtime/server/utils/auth.d.ts +10 -0
- package/dist/runtime/server/utils/auth.js +7 -0
- package/dist/runtime/server/utils/catalog.d.ts +25 -0
- package/dist/runtime/server/utils/catalog.js +129 -0
- package/dist/runtime/server/utils/membership.d.ts +3 -0
- package/dist/runtime/server/utils/membership.js +5 -0
- package/dist/runtime/server/utils/tickets.d.ts +10 -0
- package/dist/runtime/server/utils/tickets.js +122 -0
- package/dist/runtime/utils/index.d.ts +14 -0
- package/dist/runtime/utils/index.js +30 -0
- package/dist/types.d.mts +3 -0
- package/package.json +91 -3
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const translated = z.union([z.string().min(1), z.record(z.string(), z.string())]);
|
|
3
|
+
export const membership = z.union([z.literal("any"), z.array(z.string().min(1))]).nullable();
|
|
4
|
+
const date = z.coerce.date();
|
|
5
|
+
export const addonInput = z.object({
|
|
6
|
+
label: translated,
|
|
7
|
+
description: translated.nullish(),
|
|
8
|
+
price: z.number().int().min(0).optional(),
|
|
9
|
+
capacity: z.number().int().min(0).nullish(),
|
|
10
|
+
sortOrder: z.number().int().optional()
|
|
11
|
+
});
|
|
12
|
+
export const addonGroupInput = z.object({
|
|
13
|
+
title: translated,
|
|
14
|
+
required: z.boolean().optional(),
|
|
15
|
+
multiple: z.boolean().optional(),
|
|
16
|
+
sortOrder: z.number().int().optional(),
|
|
17
|
+
addons: z.array(addonInput).optional()
|
|
18
|
+
});
|
|
19
|
+
export const ticketTypeInput = z.object({
|
|
20
|
+
key: z.string().min(1).max(64),
|
|
21
|
+
name: translated,
|
|
22
|
+
description: translated.nullish(),
|
|
23
|
+
price: z.number().int().min(0).optional(),
|
|
24
|
+
currency: z.string().length(3).transform((value) => value.toUpperCase()).optional(),
|
|
25
|
+
hidePrice: z.boolean().optional(),
|
|
26
|
+
capacity: z.number().int().min(0).nullish(),
|
|
27
|
+
saleStartsAt: date.nullish(),
|
|
28
|
+
saleEndsAt: date.nullish(),
|
|
29
|
+
maxPerUser: z.number().int().min(1).optional(),
|
|
30
|
+
requiresMembership: membership.optional(),
|
|
31
|
+
isDefault: z.boolean().optional(),
|
|
32
|
+
sortOrder: z.number().int().optional(),
|
|
33
|
+
addonGroups: z.array(addonGroupInput).optional()
|
|
34
|
+
});
|
|
35
|
+
export const eventInput = z.object({
|
|
36
|
+
slug: z.string().min(1).max(128).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and dashes."),
|
|
37
|
+
name: translated,
|
|
38
|
+
description: translated.nullish(),
|
|
39
|
+
body: translated.nullish(),
|
|
40
|
+
image: z.string().nullish(),
|
|
41
|
+
location: z.string().nullish(),
|
|
42
|
+
locationUrl: z.string().nullish(),
|
|
43
|
+
startsAt: date,
|
|
44
|
+
endsAt: date.nullish(),
|
|
45
|
+
status: z.enum(["draft", "published", "archived"]).optional(),
|
|
46
|
+
requiresMembership: membership.optional(),
|
|
47
|
+
ticketTypes: z.array(ticketTypeInput).optional()
|
|
48
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { addonGroupInput, ticketTypeInput } from './admin-schemas.js';
|
|
3
|
+
type TicketTypeInput = z.output<typeof ticketTypeInput>;
|
|
4
|
+
type AddonGroupInput = z.output<typeof addonGroupInput>;
|
|
5
|
+
export declare function insertAddonGroup(ticketTypeId: string, input: AddonGroupInput): Promise<any>;
|
|
6
|
+
export declare function insertTicketType(eventId: string, input: TicketTypeInput): Promise<any>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { db, schema } from "@nuxthub/db";
|
|
2
|
+
import { newId } from "../../utils/index.js";
|
|
3
|
+
export async function insertAddonGroup(ticketTypeId, input) {
|
|
4
|
+
const { addons, ...group } = input;
|
|
5
|
+
const [row] = await db.insert(schema.eventAddonGroups).values({ id: newId("agr"), ticketTypeId, ...group }).returning();
|
|
6
|
+
for (const addon of addons ?? []) {
|
|
7
|
+
await db.insert(schema.eventAddons).values({ id: newId("adn"), groupId: row.id, ...addon, description: addon.description ?? null, capacity: addon.capacity ?? null });
|
|
8
|
+
}
|
|
9
|
+
return row;
|
|
10
|
+
}
|
|
11
|
+
export async function insertTicketType(eventId, input) {
|
|
12
|
+
const { addonGroups, ...type } = input;
|
|
13
|
+
const [row] = await db.insert(schema.eventTicketTypes).values({
|
|
14
|
+
id: newId("ttp"),
|
|
15
|
+
eventId,
|
|
16
|
+
...type,
|
|
17
|
+
capacity: type.capacity ?? null,
|
|
18
|
+
saleStartsAt: type.saleStartsAt ?? null,
|
|
19
|
+
saleEndsAt: type.saleEndsAt ?? null,
|
|
20
|
+
requiresMembership: type.requiresMembership ?? null
|
|
21
|
+
}).returning();
|
|
22
|
+
for (const group of addonGroups ?? []) await insertAddonGroup(row.id, group);
|
|
23
|
+
return row;
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
/**
|
|
3
|
+
* Gate for staff routes (event/ticket management, check-in). This repo has no shared permission
|
|
4
|
+
* system, so the host app decides through the `events:authorize` Nitro hook. Denied by default:
|
|
5
|
+
*
|
|
6
|
+
* nitroApp.hooks.hook('events:authorize', (ctx) => { ctx.allowed = isStaff(ctx.userId) })
|
|
7
|
+
*/
|
|
8
|
+
export declare function requireEventsManager(event: H3Event): Promise<{
|
|
9
|
+
userId: number;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export async function requireEventsManager(event) {
|
|
2
|
+
const { user } = await requireUserSession(event);
|
|
3
|
+
const ctx = { event, userId: user.id, allowed: false };
|
|
4
|
+
await useNitroApp().hooks.callHook("events:authorize", ctx);
|
|
5
|
+
if (!ctx.allowed) throw createError({ statusCode: 403, statusMessage: "You are not allowed to manage events." });
|
|
6
|
+
return { userId: user.id };
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { schema } from '@nuxthub/db';
|
|
2
|
+
import type { EventDetailDto, EventSummaryDto, TicketDto } from '../../../types.js';
|
|
3
|
+
type EventRow = typeof schema.events.$inferSelect;
|
|
4
|
+
type TicketRow = typeof schema.eventTickets.$inferSelect;
|
|
5
|
+
/** Tickets that currently occupy a seat: paid ones, and unpaid ones still inside their reservation window. */
|
|
6
|
+
export declare function holdsSeat(): import("drizzle-orm").SQL<unknown>;
|
|
7
|
+
export declare function countSeats(ticketTypeId: string): Promise<number>;
|
|
8
|
+
export declare function countAddonSeats(addonId: string): Promise<number>;
|
|
9
|
+
export declare function toEventSummary(row: EventRow): EventSummaryDto;
|
|
10
|
+
export declare function listPublishedEvents(): Promise<EventSummaryDto[]>;
|
|
11
|
+
export declare function getPublishedEventRow(slug: string): Promise<EventRow | null>;
|
|
12
|
+
/** Loads the catalogue rows of an event: its ticket types with addon groups and addons. */
|
|
13
|
+
export declare function loadCatalog(eventId: string): Promise<{
|
|
14
|
+
types: any;
|
|
15
|
+
groups: any;
|
|
16
|
+
addons: any;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* The public event detail. For a signed-in user (`userId`) each ticket type also says whether they
|
|
20
|
+
* may buy it (`blocked`), so the UI can disable it with the right badge.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getEventDetail(row: EventRow, userId?: number): Promise<EventDetailDto>;
|
|
23
|
+
/** Maps ticket rows to DTOs (joining event + ticket type names). The QR `code` only goes out for paid tickets. */
|
|
24
|
+
export declare function toTicketDtos(rows: TicketRow[]): Promise<TicketDto[]>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { db, schema } from "@nuxthub/db";
|
|
2
|
+
import { and, asc, eq, gt, inArray, like, or, sql } from "drizzle-orm";
|
|
3
|
+
import { normalizeMembership } from "../../utils/index.js";
|
|
4
|
+
import { meetsMembership } from "./membership.js";
|
|
5
|
+
export function holdsSeat() {
|
|
6
|
+
const cutoff = new Date(Date.now() - useRuntimeConfig().events.reservationMinutes * 6e4);
|
|
7
|
+
return or(
|
|
8
|
+
eq(schema.eventTickets.status, "paid"),
|
|
9
|
+
and(eq(schema.eventTickets.status, "pending"), gt(schema.eventTickets.createdAt, cutoff))
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
export async function countSeats(ticketTypeId) {
|
|
13
|
+
const [row] = await db.select({ count: sql`count(*)` }).from(schema.eventTickets).where(and(eq(schema.eventTickets.ticketTypeId, ticketTypeId), holdsSeat()));
|
|
14
|
+
return Number(row?.count ?? 0);
|
|
15
|
+
}
|
|
16
|
+
export async function countAddonSeats(addonId) {
|
|
17
|
+
const [row] = await db.select({ count: sql`count(*)` }).from(schema.eventTickets).where(and(like(schema.eventTickets.addons, `%"id":"${addonId}"%`), holdsSeat()));
|
|
18
|
+
return Number(row?.count ?? 0);
|
|
19
|
+
}
|
|
20
|
+
export function toEventSummary(row) {
|
|
21
|
+
return {
|
|
22
|
+
id: row.id,
|
|
23
|
+
slug: row.slug,
|
|
24
|
+
name: row.name,
|
|
25
|
+
description: row.description ?? null,
|
|
26
|
+
image: row.image,
|
|
27
|
+
location: row.location,
|
|
28
|
+
startsAt: row.startsAt.toISOString(),
|
|
29
|
+
endsAt: row.endsAt?.toISOString() ?? null,
|
|
30
|
+
requiresMembership: normalizeMembership(row.requiresMembership)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function listPublishedEvents() {
|
|
34
|
+
const rows = await db.select().from(schema.events).where(eq(schema.events.status, "published")).orderBy(asc(schema.events.startsAt));
|
|
35
|
+
return rows.map(toEventSummary);
|
|
36
|
+
}
|
|
37
|
+
export async function getPublishedEventRow(slug) {
|
|
38
|
+
const [row] = await db.select().from(schema.events).where(and(eq(schema.events.slug, slug), eq(schema.events.status, "published"))).limit(1);
|
|
39
|
+
return row ?? null;
|
|
40
|
+
}
|
|
41
|
+
export async function loadCatalog(eventId) {
|
|
42
|
+
const types = await db.select().from(schema.eventTicketTypes).where(eq(schema.eventTicketTypes.eventId, eventId)).orderBy(asc(schema.eventTicketTypes.sortOrder), asc(schema.eventTicketTypes.createdAt));
|
|
43
|
+
const typeIds = types.map((type) => type.id);
|
|
44
|
+
const groups = typeIds.length ? await db.select().from(schema.eventAddonGroups).where(inArray(schema.eventAddonGroups.ticketTypeId, typeIds)).orderBy(asc(schema.eventAddonGroups.sortOrder)) : [];
|
|
45
|
+
const groupIds = groups.map((group) => group.id);
|
|
46
|
+
const addons = groupIds.length ? await db.select().from(schema.eventAddons).where(inArray(schema.eventAddons.groupId, groupIds)).orderBy(asc(schema.eventAddons.sortOrder)) : [];
|
|
47
|
+
return { types, groups, addons };
|
|
48
|
+
}
|
|
49
|
+
export async function getEventDetail(row, userId) {
|
|
50
|
+
const { types, groups, addons } = await loadCatalog(row.id);
|
|
51
|
+
const eventRequirement = normalizeMembership(row.requiresMembership);
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const ticketTypes = await Promise.all(types.map(async (type) => {
|
|
54
|
+
const seats = type.capacity === null ? null : await countSeats(type.id);
|
|
55
|
+
const remaining = type.capacity === null ? null : Math.max(type.capacity - (seats ?? 0), 0);
|
|
56
|
+
const saleState = type.saleStartsAt && type.saleStartsAt.getTime() > now ? "not_started" : type.saleEndsAt && type.saleEndsAt.getTime() < now ? "ended" : remaining === 0 ? "sold_out" : "open";
|
|
57
|
+
const typeRequirement = normalizeMembership(type.requiresMembership);
|
|
58
|
+
let blocked = null;
|
|
59
|
+
if (userId !== void 0) {
|
|
60
|
+
if (!await meetsMembership(userId, eventRequirement) || !await meetsMembership(userId, typeRequirement)) blocked = "membership_required";
|
|
61
|
+
else {
|
|
62
|
+
const [owned] = await db.select({ count: sql`count(*)` }).from(schema.eventTickets).where(and(
|
|
63
|
+
eq(schema.eventTickets.userId, userId),
|
|
64
|
+
eq(schema.eventTickets.ticketTypeId, type.id),
|
|
65
|
+
holdsSeat()
|
|
66
|
+
));
|
|
67
|
+
if (Number(owned?.count ?? 0) >= type.maxPerUser) blocked = "limit_reached";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const addonGroups = await Promise.all(groups.filter((group) => group.ticketTypeId === type.id).map(async (group) => ({
|
|
71
|
+
id: group.id,
|
|
72
|
+
title: group.title,
|
|
73
|
+
required: group.required,
|
|
74
|
+
multiple: group.multiple,
|
|
75
|
+
addons: await Promise.all(addons.filter((addon) => addon.groupId === group.id).map(async (addon) => ({
|
|
76
|
+
id: addon.id,
|
|
77
|
+
label: addon.label,
|
|
78
|
+
description: addon.description ?? null,
|
|
79
|
+
price: addon.price,
|
|
80
|
+
soldOut: addon.capacity !== null && await countAddonSeats(addon.id) >= addon.capacity
|
|
81
|
+
})))
|
|
82
|
+
})));
|
|
83
|
+
return {
|
|
84
|
+
id: type.id,
|
|
85
|
+
key: type.key,
|
|
86
|
+
name: type.name,
|
|
87
|
+
description: type.description ?? null,
|
|
88
|
+
price: type.price,
|
|
89
|
+
currency: type.currency,
|
|
90
|
+
hidePrice: type.hidePrice,
|
|
91
|
+
isDefault: type.isDefault,
|
|
92
|
+
requiresMembership: typeRequirement,
|
|
93
|
+
remaining,
|
|
94
|
+
saleStartsAt: type.saleStartsAt?.toISOString() ?? null,
|
|
95
|
+
saleEndsAt: type.saleEndsAt?.toISOString() ?? null,
|
|
96
|
+
saleState,
|
|
97
|
+
blocked,
|
|
98
|
+
addonGroups
|
|
99
|
+
};
|
|
100
|
+
}));
|
|
101
|
+
return { ...toEventSummary(row), body: row.body ?? null, locationUrl: row.locationUrl, ticketTypes };
|
|
102
|
+
}
|
|
103
|
+
export async function toTicketDtos(rows) {
|
|
104
|
+
if (!rows.length) return [];
|
|
105
|
+
const eventRows = await db.select().from(schema.events).where(inArray(schema.events.id, [...new Set(rows.map((row) => row.eventId))]));
|
|
106
|
+
const typeRows = await db.select().from(schema.eventTicketTypes).where(inArray(schema.eventTicketTypes.id, [...new Set(rows.map((row) => row.ticketTypeId))]));
|
|
107
|
+
const eventsById = new Map(eventRows.map((row) => [row.id, row]));
|
|
108
|
+
const typesById = new Map(typeRows.map((row) => [row.id, row]));
|
|
109
|
+
return rows.map((row) => {
|
|
110
|
+
const event = eventsById.get(row.eventId);
|
|
111
|
+
return {
|
|
112
|
+
id: row.id,
|
|
113
|
+
eventId: row.eventId,
|
|
114
|
+
eventSlug: event?.slug ?? "",
|
|
115
|
+
eventName: event?.name ?? "",
|
|
116
|
+
startsAt: event?.startsAt.toISOString() ?? row.createdAt.toISOString(),
|
|
117
|
+
ticketTypeId: row.ticketTypeId,
|
|
118
|
+
ticketTypeName: typesById.get(row.ticketTypeId)?.name ?? "",
|
|
119
|
+
status: row.status,
|
|
120
|
+
addons: row.addons,
|
|
121
|
+
amount: row.amount,
|
|
122
|
+
currency: row.currency,
|
|
123
|
+
paymentId: row.paymentId,
|
|
124
|
+
code: row.status === "paid" ? row.code : null,
|
|
125
|
+
checkedInAt: row.checkedInAt?.toISOString() ?? null,
|
|
126
|
+
createdAt: row.createdAt.toISOString()
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { MembershipRequirement } from '../../types.js';
|
|
2
|
+
/** True when `requirement` is null, or the user holds a matching active subscription. */
|
|
3
|
+
export declare function meetsMembership(userId: number, requirement: MembershipRequirement | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { PurchaseBody, PurchaseResponse, TicketDto } from '../../../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The whole purchase: validates the request against the catalogue, takes a seat, and either issues a
|
|
5
|
+
* free ticket straight away or hands off to `@frigear-nu/payments` (the ticket turns `paid` when the
|
|
6
|
+
* `payments:paid` hook fires, see `plugins/payment-hooks.ts`).
|
|
7
|
+
*/
|
|
8
|
+
export declare function purchaseTicket(event: H3Event, userId: number, slug: string, body: PurchaseBody): Promise<PurchaseResponse>;
|
|
9
|
+
export declare function listUserTickets(userId: number): Promise<TicketDto[]>;
|
|
10
|
+
export declare function getUserTicket(userId: number, ticketId: string): Promise<TicketDto>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { db, schema } from "@nuxthub/db";
|
|
2
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
|
+
import { newId, newTicketCode, normalizeMembership, resolveSameOriginUrl, translate } from "../../utils/index.js";
|
|
4
|
+
import { countAddonSeats, countSeats, getPublishedEventRow, holdsSeat, loadCatalog, toTicketDtos } from "./catalog.js";
|
|
5
|
+
import { meetsMembership } from "./membership.js";
|
|
6
|
+
const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
|
|
7
|
+
function returnUrls(event, slug, ticketId, body) {
|
|
8
|
+
const origin = getRequestURL(event).origin;
|
|
9
|
+
const pages = useRuntimeConfig().public.events?.pages ?? {};
|
|
10
|
+
const eventPath = (pages.event || "/events/:slug").replace(":slug", encodeURIComponent(slug));
|
|
11
|
+
const resolve = (input, status, name) => {
|
|
12
|
+
if (input) {
|
|
13
|
+
const safe = resolveSameOriginUrl(input, origin);
|
|
14
|
+
if (!safe) throw fail(400, `${name} must be a relative path or an URL on the same origin.`);
|
|
15
|
+
return safe;
|
|
16
|
+
}
|
|
17
|
+
return `${origin}${eventPath}?ticket=${ticketId}&payment=${status}`;
|
|
18
|
+
};
|
|
19
|
+
return { successUrl: resolve(body.successUrl, "success", "successUrl"), cancelUrl: resolve(body.cancelUrl, "cancel", "cancelUrl") };
|
|
20
|
+
}
|
|
21
|
+
async function release(ticketId, status = "cancelled") {
|
|
22
|
+
await db.update(schema.eventTickets).set({ status }).where(eq(schema.eventTickets.id, ticketId));
|
|
23
|
+
}
|
|
24
|
+
export async function purchaseTicket(event, userId, slug, body) {
|
|
25
|
+
const eventRow = await getPublishedEventRow(slug);
|
|
26
|
+
if (!eventRow) throw fail(404, "Event not found.");
|
|
27
|
+
if (eventRow.endsAt ? eventRow.endsAt.getTime() < Date.now() : eventRow.startsAt.getTime() < Date.now() - 24 * 36e5) {
|
|
28
|
+
throw fail(409, "This event is over.");
|
|
29
|
+
}
|
|
30
|
+
const { types, groups, addons } = await loadCatalog(eventRow.id);
|
|
31
|
+
const type = types.find((item) => item.id === body.ticketTypeId);
|
|
32
|
+
if (!type) throw fail(404, "Ticket type not found.");
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (type.saleStartsAt && type.saleStartsAt.getTime() > now) throw fail(409, "Ticket sales have not started yet.");
|
|
35
|
+
if (type.saleEndsAt && type.saleEndsAt.getTime() < now) throw fail(409, "Ticket sales have ended.");
|
|
36
|
+
const requirements = [normalizeMembership(eventRow.requiresMembership), normalizeMembership(type.requiresMembership)];
|
|
37
|
+
for (const requirement of requirements) {
|
|
38
|
+
if (!await meetsMembership(userId, requirement)) throw fail(403, "An active membership is required for this ticket.");
|
|
39
|
+
}
|
|
40
|
+
const typeGroups = groups.filter((group) => group.ticketTypeId === type.id);
|
|
41
|
+
const typeAddons = addons.filter((addon) => typeGroups.some((group) => group.id === addon.groupId));
|
|
42
|
+
const addonIds = [...new Set(body.addonIds ?? [])];
|
|
43
|
+
const chosen = addonIds.map((id2) => {
|
|
44
|
+
const addon = typeAddons.find((item) => item.id === id2);
|
|
45
|
+
if (!addon) throw fail(400, `Addon ${id2} does not belong to this ticket type.`);
|
|
46
|
+
return addon;
|
|
47
|
+
});
|
|
48
|
+
for (const group of typeGroups) {
|
|
49
|
+
const inGroup = chosen.filter((addon) => addon.groupId === group.id);
|
|
50
|
+
if (group.required && inGroup.length === 0) throw fail(400, `Choose an option for "${translate(group.title)}".`);
|
|
51
|
+
if (!group.multiple && inGroup.length > 1) throw fail(400, `Only one option can be chosen for "${translate(group.title)}".`);
|
|
52
|
+
}
|
|
53
|
+
for (const addon of chosen) {
|
|
54
|
+
if (addon.capacity !== null && await countAddonSeats(addon.id) >= addon.capacity) throw fail(409, `"${translate(addon.label)}" is sold out.`);
|
|
55
|
+
}
|
|
56
|
+
if (type.capacity !== null && await countSeats(type.id) >= type.capacity) throw fail(409, "This ticket type is sold out.");
|
|
57
|
+
await useNitroApp().hooks.callHook("events:purchase:before", { event, userId, eventId: eventRow.id, ticketTypeId: type.id, addonIds });
|
|
58
|
+
const [owned] = await db.select({ count: sql`count(*)` }).from(schema.eventTickets).where(and(eq(schema.eventTickets.userId, userId), eq(schema.eventTickets.ticketTypeId, type.id), holdsSeat()));
|
|
59
|
+
if (Number(owned?.count ?? 0) >= type.maxPerUser) throw fail(409, "You have reached the ticket limit for this ticket type.");
|
|
60
|
+
const amount = type.price + chosen.reduce((total, addon) => total + addon.price, 0);
|
|
61
|
+
const id = newId("tkt");
|
|
62
|
+
const [inserted] = await db.insert(schema.eventTickets).values({
|
|
63
|
+
id,
|
|
64
|
+
eventId: eventRow.id,
|
|
65
|
+
ticketTypeId: type.id,
|
|
66
|
+
userId,
|
|
67
|
+
status: amount === 0 ? "paid" : "pending",
|
|
68
|
+
addons: chosen.map((addon) => ({ id: addon.id, label: addon.label, price: addon.price })),
|
|
69
|
+
amount,
|
|
70
|
+
currency: type.currency,
|
|
71
|
+
code: newTicketCode()
|
|
72
|
+
}).returning();
|
|
73
|
+
if (type.capacity !== null) {
|
|
74
|
+
const winners = await db.select({ id: schema.eventTickets.id }).from(schema.eventTickets).where(and(eq(schema.eventTickets.ticketTypeId, type.id), holdsSeat())).orderBy(sql`rowid`).limit(type.capacity);
|
|
75
|
+
if (!winners.some((winner) => winner.id === id)) {
|
|
76
|
+
await release(id);
|
|
77
|
+
throw fail(409, "This ticket type is sold out.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const addon of chosen.filter((item) => item.capacity !== null)) {
|
|
81
|
+
const winners = await db.select({ id: schema.eventTickets.id }).from(schema.eventTickets).where(and(sql`${schema.eventTickets.addons} like ${`%"id":"${addon.id}"%`}`, holdsSeat())).orderBy(sql`rowid`).limit(addon.capacity);
|
|
82
|
+
if (!winners.some((winner) => winner.id === id)) {
|
|
83
|
+
await release(id);
|
|
84
|
+
throw fail(409, `"${translate(addon.label)}" is sold out.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const [dto] = await toTicketDtos([inserted]);
|
|
88
|
+
if (amount === 0) {
|
|
89
|
+
await useNitroApp().hooks.callHook("events:ticket:paid", { ticket: dto });
|
|
90
|
+
return { ticket: dto };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const urls = returnUrls(event, eventRow.slug, id, body);
|
|
94
|
+
const { payment, url } = await createPayment({
|
|
95
|
+
userId,
|
|
96
|
+
description: `${translate(eventRow.name)} - ${translate(type.name)}`,
|
|
97
|
+
items: [
|
|
98
|
+
...type.price > 0 ? [{ name: `${translate(eventRow.name)} - ${translate(type.name)}`, amount: type.price, currency: type.currency }] : [],
|
|
99
|
+
...chosen.filter((addon) => addon.price > 0).map((addon) => ({ name: translate(addon.label), amount: addon.price, currency: type.currency }))
|
|
100
|
+
],
|
|
101
|
+
reference: { type: "event_ticket", id },
|
|
102
|
+
expiresInMinutes: useRuntimeConfig().events.reservationMinutes,
|
|
103
|
+
...urls
|
|
104
|
+
}, event);
|
|
105
|
+
await db.update(schema.eventTickets).set({ paymentId: payment.id }).where(eq(schema.eventTickets.id, id));
|
|
106
|
+
const [withPayment] = await toTicketDtos([{ ...inserted, paymentId: payment.id }]);
|
|
107
|
+
return { ticket: withPayment, url };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await release(id);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function listUserTickets(userId) {
|
|
114
|
+
const rows = await db.select().from(schema.eventTickets).where(eq(schema.eventTickets.userId, userId)).orderBy(sql`${schema.eventTickets.createdAt} desc`);
|
|
115
|
+
return toTicketDtos(rows);
|
|
116
|
+
}
|
|
117
|
+
export async function getUserTicket(userId, ticketId) {
|
|
118
|
+
const [row] = await db.select().from(schema.eventTickets).where(and(eq(schema.eventTickets.id, ticketId), eq(schema.eventTickets.userId, userId))).limit(1);
|
|
119
|
+
if (!row) throw fail(404, "Ticket not found.");
|
|
120
|
+
const [dto] = await toTicketDtos([row]);
|
|
121
|
+
return dto;
|
|
122
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MembershipRequirement, Translated } from '../types.js';
|
|
2
|
+
export type { AddonDto, AddonGroupDto, AuthorizeContext, CheckInResponse, EventDetailDto, EventStatus, EventSummaryDto, MembershipRequirement, ModuleOptions, PurchaseBeforeContext, PurchaseBody, PurchaseResponse, TicketDto, TicketHookContext, TicketStatus, TicketTypeDto, Translated, } from '../types.js';
|
|
3
|
+
/** Picks the text for a locale, falling back to `en`, then to the first available entry. */
|
|
4
|
+
export declare function translate(value: Translated | null | undefined, locale?: string): string;
|
|
5
|
+
/** Formats minor units, e.g. `formatPrice(15000, 'DKK', 'da')` -> `150,00 kr.`. */
|
|
6
|
+
export declare function formatPrice(amount: number, currency: string, locale?: string): string;
|
|
7
|
+
/** Normalises a stored requirement: empty plan lists mean "any active subscription". */
|
|
8
|
+
export declare function normalizeMembership(value: MembershipRequirement | undefined): MembershipRequirement;
|
|
9
|
+
/** Resolves a relative path against `origin`, and only accepts absolute URLs on the same origin. */
|
|
10
|
+
export declare function resolveSameOriginUrl(input: string, origin: string): string | null;
|
|
11
|
+
/** Random, prefixed id (`tkt_...`), Web Crypto only so it works on Workers. */
|
|
12
|
+
export declare function newId(prefix: string): string;
|
|
13
|
+
/** A longer random token for ticket QR codes. */
|
|
14
|
+
export declare function newTicketCode(): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function translate(value, locale = "en") {
|
|
2
|
+
if (!value) return "";
|
|
3
|
+
if (typeof value === "string") return value;
|
|
4
|
+
return value[locale] ?? value.en ?? Object.values(value)[0] ?? "";
|
|
5
|
+
}
|
|
6
|
+
export function formatPrice(amount, currency, locale = "en") {
|
|
7
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount / 100);
|
|
8
|
+
}
|
|
9
|
+
export function normalizeMembership(value) {
|
|
10
|
+
if (value === "any") return "any";
|
|
11
|
+
if (Array.isArray(value)) return value.length ? value : "any";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
export function resolveSameOriginUrl(input, origin) {
|
|
15
|
+
if (input.startsWith("/")) {
|
|
16
|
+
if (input.startsWith("//") || input.startsWith("/\\")) return null;
|
|
17
|
+
return `${origin}${input}`;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return new URL(input).origin === new URL(origin).origin ? input : null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function newId(prefix) {
|
|
26
|
+
return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
27
|
+
}
|
|
28
|
+
export function newTicketCode() {
|
|
29
|
+
return `${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`;
|
|
30
|
+
}
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { default } from './module.mjs'
|
|
2
|
+
|
|
3
|
+
export { type AddonDto, type AddonGroupDto, type AuthorizeContext, type CheckInResponse, type EventDetailDto, type EventStatus, type EventSummaryDto, type EventsRuntimeConfig, type MembershipRequirement, type ModuleOptions, type PublicModuleOptions, type PurchaseBeforeContext, type PurchaseBody, type PurchaseResponse, type TicketDto, type TicketHookContext, type TicketStatus, type TicketTypeDto, type Translated } from './module.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,94 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frigear-nu/events",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Events with ticket types, addons, capacity, membership requirements, Stripe payments and QR check-in for the frigear ecosystem, built on @frigear-nu/payments.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/frigear-nu/frigear-nu.git",
|
|
8
|
+
"directory": "packages/events"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/module.mjs",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/types.d.mts",
|
|
16
|
+
"import": "./dist/module.mjs",
|
|
17
|
+
"default": "./dist/module.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./utils": {
|
|
20
|
+
"types": "./dist/runtime/utils/index.d.ts",
|
|
21
|
+
"import": "./dist/runtime/utils/index.js",
|
|
22
|
+
"default": "./dist/runtime/utils/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"workspaces": [
|
|
29
|
+
"playground"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@frigear-nu/menu": "0.0.2",
|
|
33
|
+
"@frigear-nu/module-kit": "0.1.0",
|
|
34
|
+
"@frigear-nu/payments": "0.0.2",
|
|
35
|
+
"@frigear-nu/stripe": "0.0.2",
|
|
36
|
+
"@frigear-nu/subscriptions": "0.0.2",
|
|
37
|
+
"@frigear-nu/users": "1.0.2",
|
|
38
|
+
"@libsql/client": "^0.18.0",
|
|
39
|
+
"@nuxt/kit": "^4.6.0",
|
|
40
|
+
"@vueuse/core": "^15.0.0",
|
|
41
|
+
"drizzle-kit": "^0.31.11",
|
|
42
|
+
"drizzle-orm": "^0.45.3",
|
|
43
|
+
"h3": "^1.15.11",
|
|
44
|
+
"stripe": "^23.0.0",
|
|
45
|
+
"unstorage": "^1.17.5",
|
|
46
|
+
"uqr": "^0.1.3",
|
|
47
|
+
"zod": "^4.6.5"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@nuxt/ui": "^4.10.0",
|
|
51
|
+
"@nuxthub/core": "^0.10.8"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@nuxt/ui": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@nuxt/devtools": "^3.4.2",
|
|
60
|
+
"@nuxt/eslint-config": "^1.17.0",
|
|
61
|
+
"@nuxt/module-builder": "^1.0.3",
|
|
62
|
+
"@nuxt/schema": "^4.6.0",
|
|
63
|
+
"@nuxt/test-utils": "^4.3.2",
|
|
64
|
+
"@nuxt/ui": "^4.11.0",
|
|
65
|
+
"@nuxthub/core": "^0.10.8",
|
|
66
|
+
"@types/node": "latest",
|
|
67
|
+
"changelogen": "^0.6.2",
|
|
68
|
+
"eslint": "^10.10.0",
|
|
69
|
+
"nuxt": "^4.6.0",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"vitest": "^5.0.1",
|
|
72
|
+
"vue-tsc": "^3.3.11"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"postinstall": "nuxt-module-build build --stub && nuxt prepare",
|
|
76
|
+
"build": "nuxt-module-build build",
|
|
77
|
+
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
78
|
+
"dev:build": "nuxt build playground",
|
|
79
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
80
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
81
|
+
"lint": "eslint .",
|
|
82
|
+
"lint:fix": "eslint . --fix",
|
|
83
|
+
"test": "vitest run",
|
|
84
|
+
"test:watch": "vitest watch",
|
|
85
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
86
|
+
},
|
|
87
|
+
"typesVersions": {
|
|
88
|
+
"*": {
|
|
89
|
+
".": [
|
|
90
|
+
"./dist/types.d.mts"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
6
94
|
}
|