Skip to content

Commit 8b6665b

Browse files
committed
feat: Add plugin to list squadup tickets
1 parent d58090a commit 8b6665b

13 files changed

Lines changed: 1719 additions & 1 deletion

package-lock.json

Lines changed: 1028 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** @type {import("eslint").Linter.Config} */
2+
module.exports = {
3+
extends: [require.resolve("@shared/eslint/node.js")],
4+
parserOptions: {
5+
project: "tsconfig.json",
6+
tsconfigRootDir: __dirname,
7+
sourceType: "module",
8+
},
9+
ignorePatterns: ["**/*.test.ts", "**/*.spec.ts"],
10+
};

packages/squadup-nodejs/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# SuperTokens SquadUp Plugin
2+
3+
Adds an authenticated endpoint for listing SquadUp tickets for the current SuperTokens user.
4+
5+
```ts
6+
import SquadUpPlugin from "@supertokens-plugins/squadup-nodejs";
7+
8+
SuperTokens.init({
9+
experimental: {
10+
plugins: [
11+
SquadUpPlugin.init({
12+
apiKey: process.env.SQUADUP_API_KEY!,
13+
}),
14+
],
15+
},
16+
});
17+
```
18+
19+
## Endpoint
20+
21+
`GET /auth/plugin/squadup/tickets`
22+
23+
The endpoint requires a SuperTokens session. It uses a passwordless email login method, or a verified third-party email login method, as the SquadUp lookup email.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@supertokens-plugins/squadup-nodejs",
3+
"version": "0.1.0",
4+
"description": "SquadUp ticketing plugin for SuperTokens",
5+
"main": "./dist/index.js",
6+
"module": "./dist/index.mjs",
7+
"types": "./dist/index.d.ts",
8+
"files": [
9+
"dist",
10+
"src"
11+
],
12+
"scripts": {
13+
"build": "tsup",
14+
"lint": "eslint ./src --ext .ts",
15+
"test": "TEST_MODE=testing vitest run --pool=forks"
16+
},
17+
"keywords": [
18+
"supertokens",
19+
"squadup",
20+
"ticketing",
21+
"plugin"
22+
],
23+
"license": "Apache-2.0",
24+
"dependencies": {},
25+
"peerDependencies": {
26+
"supertokens-node": ">=23.0.0"
27+
},
28+
"devDependencies": {
29+
"@shared/eslint": "*",
30+
"@shared/nodejs": "*",
31+
"@shared/tsconfig": "*",
32+
"@types/node": "^20.0.0",
33+
"eslint": "^8.57.0",
34+
"tsup": "^8.0.2",
35+
"typescript": "^5.7.3",
36+
"vitest": "^1.3.1"
37+
},
38+
"publishConfig": {
39+
"access": "public"
40+
}
41+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const PLUGIN_ID = "supertokens-plugin-squadup";
2+
export const PLUGIN_SDK_VERSION = ["23.0.0", "23.0.1", ">=23.0.1"];
3+
export const HANDLE_BASE_PATH = "/plugin/squadup";
4+
5+
export const DEFAULT_SQUADUP_BASE_URL = "https://www.squadup.com";
6+
export const DEFAULT_PAGE_SIZE = 100;
7+
export const DEFAULT_TICKET_AVAILABILITY_WINDOW_MS = 2 * 60 * 60 * 1000;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from "./plugin";
2+
import { init } from "./plugin";
3+
export { init };
4+
export default { init };
5+
export * from "./types";
6+
export * from "./constants";
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { buildLogger } from "@shared/nodejs";
2+
import { PLUGIN_ID, PLUGIN_SDK_VERSION } from "./constants";
3+
4+
export const { logDebugMessage, enableDebugLogs } = buildLogger(
5+
PLUGIN_ID,
6+
PLUGIN_SDK_VERSION,
7+
);
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import SuperTokens from "supertokens-node";
3+
import { init } from "./plugin";
4+
import {
5+
getEmailFromSession,
6+
listTickets,
7+
mapSquadUpAttendeesToEvents,
8+
parsePageSize,
9+
} from "./pluginImplementation";
10+
import {
11+
DEFAULT_PAGE_SIZE,
12+
DEFAULT_SQUADUP_BASE_URL,
13+
DEFAULT_TICKET_AVAILABILITY_WINDOW_MS,
14+
HANDLE_BASE_PATH,
15+
} from "./constants";
16+
import { SquadUpPluginNormalisedConfig } from "./types";
17+
18+
vi.mock("supertokens-node", () => ({
19+
default: {
20+
getUser: vi.fn(),
21+
},
22+
}));
23+
24+
const mockGetUser = vi.mocked(SuperTokens.getUser);
25+
26+
const baseConfig: SquadUpPluginNormalisedConfig = {
27+
apiKey: "squadup-api-key",
28+
baseUrl: DEFAULT_SQUADUP_BASE_URL,
29+
defaultPageSize: DEFAULT_PAGE_SIZE,
30+
ticketAvailabilityWindowMs: DEFAULT_TICKET_AVAILABILITY_WINDOW_MS,
31+
};
32+
33+
describe("squadup-nodejs plugin", () => {
34+
beforeEach(() => {
35+
vi.restoreAllMocks();
36+
vi.useRealTimers();
37+
});
38+
39+
it("throws if apiKey is missing", () => {
40+
expect(() => init({ apiKey: "" })).toThrow(
41+
"Missing apiKey in SquadUp plugin config",
42+
);
43+
});
44+
45+
it("throws if defaultPageSize is invalid", () => {
46+
expect(() => init({ apiKey: "key", defaultPageSize: 0 })).toThrow(
47+
"defaultPageSize must be a positive integer",
48+
);
49+
});
50+
51+
it("throws if ticketAvailabilityWindowMs is invalid", () => {
52+
expect(() =>
53+
init({ apiKey: "key", ticketAvailabilityWindowMs: -1 }),
54+
).toThrow("ticketAvailabilityWindowMs must be non-negative");
55+
});
56+
57+
it("registers an authenticated tickets route", () => {
58+
const plugin = init({ apiKey: "key" });
59+
const routeResult = plugin.routeHandlers!({
60+
appInfo: {
61+
apiBasePath: {
62+
getAsStringDangerous: () => "/auth",
63+
},
64+
},
65+
} as any);
66+
67+
expect(routeResult.status).toBe("OK");
68+
expect(routeResult.routeHandlers).toMatchObject([
69+
{
70+
path: `/auth${HANDLE_BASE_PATH}/tickets`,
71+
method: "get",
72+
verifySessionOptions: { sessionRequired: true },
73+
},
74+
]);
75+
});
76+
77+
it("uses passwordless email as the SquadUp lookup email", async () => {
78+
mockGetUser.mockResolvedValue({
79+
loginMethods: [
80+
{
81+
recipeId: "passwordless",
82+
email: "pass@example.com",
83+
},
84+
],
85+
} as any);
86+
87+
await expect(getEmailFromSession(fakeSession("user-1"))).resolves.toBe(
88+
"pass@example.com",
89+
);
90+
});
91+
92+
it("uses verified thirdparty email as the SquadUp lookup email", async () => {
93+
mockGetUser.mockResolvedValue({
94+
loginMethods: [
95+
{
96+
recipeId: "thirdparty",
97+
email: "thirdparty@example.com",
98+
verified: true,
99+
},
100+
],
101+
} as any);
102+
103+
await expect(getEmailFromSession(fakeSession("user-2"))).resolves.toBe(
104+
"thirdparty@example.com",
105+
);
106+
});
107+
108+
it("rejects unverified thirdparty email users", async () => {
109+
mockGetUser.mockResolvedValue({
110+
loginMethods: [
111+
{
112+
recipeId: "thirdparty",
113+
email: "thirdparty@example.com",
114+
verified: false,
115+
},
116+
],
117+
} as any);
118+
119+
await expect(
120+
getEmailFromSession(fakeSession("user-4")),
121+
).resolves.toBeUndefined();
122+
});
123+
124+
it("passes pageSize as page_size to SquadUp", async () => {
125+
const fetchMock = vi.fn().mockResolvedValue(
126+
new Response(JSON.stringify({ attendees: [] }), {
127+
status: 200,
128+
}),
129+
);
130+
vi.stubGlobal("fetch", fetchMock);
131+
132+
await listTickets(baseConfig, "tickets@example.com", 25);
133+
134+
expect(fetchMock).toHaveBeenCalledOnce();
135+
const [url, opts] = fetchMock.mock.calls[0];
136+
expect(String(url)).toBe(
137+
"https://www.squadup.com/api/v3/attendees/search?access_token=squadup-api-key",
138+
);
139+
expect(JSON.parse(opts.body)).toEqual({
140+
email: "tickets@example.com",
141+
page_size: 25,
142+
});
143+
});
144+
145+
it("returns empty events when SquadUp returns 404", async () => {
146+
vi.stubGlobal(
147+
"fetch",
148+
vi.fn().mockResolvedValue(new Response("", { status: 404 })),
149+
);
150+
151+
await expect(
152+
listTickets(baseConfig, "missing@example.com"),
153+
).resolves.toEqual({
154+
status: "OK",
155+
events: [],
156+
});
157+
});
158+
159+
it("returns ERROR for non-404 SquadUp failures", async () => {
160+
vi.stubGlobal(
161+
"fetch",
162+
vi.fn().mockResolvedValue(new Response("", { status: 500 })),
163+
);
164+
165+
await expect(
166+
listTickets(baseConfig, "error@example.com"),
167+
).resolves.toMatchObject({
168+
status: "ERROR",
169+
message: "Failed to list SquadUp tickets",
170+
});
171+
});
172+
173+
it("hides QR and PDF URLs for future tickets", () => {
174+
vi.useFakeTimers();
175+
vi.setSystemTime(new Date("2026-05-15T12:00:00.000Z"));
176+
177+
const events = mapSquadUpAttendeesToEvents(
178+
[attendeeWithTicket("2026-05-15T15:00:01.000Z")],
179+
DEFAULT_TICKET_AVAILABILITY_WINDOW_MS,
180+
);
181+
182+
expect(events[0].tickets[0].pdf_url).toBeNull();
183+
expect(events[0].tickets[0].qrcode_str).toBeNull();
184+
});
185+
186+
it("keeps QR and PDF URLs for available tickets", () => {
187+
vi.useFakeTimers();
188+
vi.setSystemTime(new Date("2026-05-15T12:00:00.000Z"));
189+
190+
const events = mapSquadUpAttendeesToEvents(
191+
[attendeeWithTicket("2026-05-15T13:59:59.000Z")],
192+
DEFAULT_TICKET_AVAILABILITY_WINDOW_MS,
193+
);
194+
195+
expect(events[0].tickets[0].pdf_url).toBe(
196+
"https://tickets.example.com/ticket.pdf",
197+
);
198+
expect(events[0].tickets[0].qrcode_str).toBe("qr-code");
199+
});
200+
201+
it("rejects invalid pageSize query values", async () => {
202+
await expect(
203+
parsePageSize(
204+
{
205+
getKeyValueFromQuery: async () => "not-a-number",
206+
} as any,
207+
100,
208+
),
209+
).resolves.toEqual({
210+
status: "BAD_INPUT_ERROR",
211+
message: "pageSize must be a positive integer",
212+
});
213+
});
214+
});
215+
216+
function fakeSession(userId: string) {
217+
return {
218+
getUserId: () => userId,
219+
} as any;
220+
}
221+
222+
function attendeeWithTicket(startAt: string) {
223+
return {
224+
event: {
225+
id: "event-1",
226+
name: "Event",
227+
start_at: startAt,
228+
end_at: startAt,
229+
image: {
230+
thumbnail_url: null,
231+
default_url: null,
232+
},
233+
location: {
234+
name: "Venue",
235+
address_line_1: "123 Main St",
236+
},
237+
location_type: "venue",
238+
},
239+
attendee_guests: [
240+
{
241+
ticket: {
242+
id: "ticket-1",
243+
type: "General Admission",
244+
event: {
245+
start_at: startAt,
246+
},
247+
pdf_url: "https://tickets.example.com/ticket.pdf",
248+
qrcode_str: "qr-code",
249+
},
250+
},
251+
],
252+
};
253+
}

0 commit comments

Comments
 (0)