diff --git a/backend/.env.example b/backend/.env.example index d70a0f7..3e783c0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -67,3 +67,17 @@ SILO_API_KEY= # value is configured on the program in Sidekick. Leave blank to disable the # endpoint entirely (all calls 401). SIDEKICK_SECRET= + +# Attend (https://github.com/hackclub/attend) — invites a buyer to the +# in-person event once they purchase the ticket shop item. Event-scoped API +# key, generated from the Attend admin UI ("regenerate API key"). Leave +# ATTEND_API_KEY or ATTEND_EVENT_SLUG blank to disable; the feature degrades +# silently when unconfigured. +ATTEND_BASE_URL=https://attend.hackclub.com +ATTEND_API_KEY= +ATTEND_EVENT_SLUG= +# shop_items.id of the ticket item that should trigger an Attend invite +ATTEND_TICKET_SHOP_ITEM_ID= +# Slack user id to DM when an invite fails after retries (an audit-log entry +# is always written regardless; this is optional extra visibility) +ATTEND_ALERT_SLACK_ID= diff --git a/backend/src/attend/attend.module.ts b/backend/src/attend/attend.module.ts new file mode 100644 index 0000000..454b1a5 --- /dev/null +++ b/backend/src/attend/attend.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AttendService } from './attend.service'; + +@Module({ + providers: [AttendService], + exports: [AttendService], +}) +export class AttendModule {} diff --git a/backend/src/attend/attend.service.spec.ts b/backend/src/attend/attend.service.spec.ts new file mode 100644 index 0000000..c0a0d8d --- /dev/null +++ b/backend/src/attend/attend.service.spec.ts @@ -0,0 +1,129 @@ +import { ConfigService } from '@nestjs/config'; +import { AttendService } from './attend.service'; +import { fetchWithTimeout } from '../fetch.util'; + +jest.mock('../fetch.util'); + +const mockFetch = fetchWithTimeout as jest.Mock; + +function makeConfig(values: Record): ConfigService { + return { get: (key: string) => values[key] } as unknown as ConfigService; +} + +describe('AttendService', () => { + afterEach(() => jest.clearAllMocks()); + + it('is disabled and never calls fetch when unconfigured', async () => { + const service = new AttendService(makeConfig({})); + const result = await service.inviteParticipant( + 'person@example.com', + 'Ada Lovelace', + ); + expect(result).toBe(false); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('posts to the configured event and splits the name', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 201 }); + const service = new AttendService( + makeConfig({ + ATTEND_BASE_URL: 'https://attend.example.com', + ATTEND_API_KEY: 'secret-key', + ATTEND_EVENT_SLUG: 'beest', + }), + ); + + const result = await service.inviteParticipant( + 'person@example.com', + 'Ada Lovelace', + ); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'https://attend.example.com/api/v1/events/beest/participants', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer secret-key', + }), + body: JSON.stringify({ + email: 'person@example.com', + first_name: 'Ada', + last_name: 'Lovelace', + }), + }), + ); + }); + + it('treats a 409 (already invited) as success', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 409 }); + const service = new AttendService( + makeConfig({ + ATTEND_API_KEY: 'secret-key', + ATTEND_EVENT_SLUG: 'beest', + }), + ); + + const result = await service.inviteParticipant('person@example.com'); + expect(result).toBe(true); + }); + + it('retries on HTTP error and returns false only after exhausting all attempts', async () => { + jest.useFakeTimers(); + mockFetch.mockResolvedValue({ ok: false, status: 422 }); + const service = new AttendService( + makeConfig({ + ATTEND_API_KEY: 'secret-key', + ATTEND_EVENT_SLUG: 'beest', + }), + ); + + const resultPromise = service.inviteParticipant('bad-email'); + await jest.runAllTimersAsync(); + const result = await resultPromise; + + expect(result).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(3); + jest.useRealTimers(); + }); + + it('retries after a transient failure and succeeds', async () => { + jest.useFakeTimers(); + mockFetch + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ ok: true, status: 201 }); + const service = new AttendService( + makeConfig({ + ATTEND_API_KEY: 'secret-key', + ATTEND_EVENT_SLUG: 'beest', + }), + ); + + const resultPromise = service.inviteParticipant('person@example.com'); + await jest.runAllTimersAsync(); + const result = await resultPromise; + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('returns false when every attempt throws', async () => { + jest.useFakeTimers(); + mockFetch.mockRejectedValue(new Error('network down')); + const service = new AttendService( + makeConfig({ + ATTEND_API_KEY: 'secret-key', + ATTEND_EVENT_SLUG: 'beest', + }), + ); + + const resultPromise = service.inviteParticipant('person@example.com'); + await jest.runAllTimersAsync(); + const result = await resultPromise; + + expect(result).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(3); + jest.useRealTimers(); + }); +}); diff --git a/backend/src/attend/attend.service.ts b/backend/src/attend/attend.service.ts new file mode 100644 index 0000000..ac7d570 --- /dev/null +++ b/backend/src/attend/attend.service.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { fetchWithTimeout } from '../fetch.util'; + +/** + * Outbound integration with Attend (https://github.com/hackclub/attend) — + * invites a participant to the in-person event once they buy their ticket. + * Best-effort like SlackNotifyService: logs and returns false on any + * failure rather than throwing, so a downed Attend never blocks a purchase. + */ +@Injectable() +export class AttendService { + private readonly logger = new Logger(AttendService.name); + private readonly baseUrl: string; + private readonly apiKey: string | undefined; + private readonly eventSlug: string | undefined; + private readonly configured: boolean; + + constructor(private configService: ConfigService) { + this.baseUrl = + this.configService.get('ATTEND_BASE_URL') ?? + 'https://attend.hackclub.com'; + this.apiKey = this.configService.get('ATTEND_API_KEY'); + this.eventSlug = this.configService.get('ATTEND_EVENT_SLUG'); + this.configured = !!this.apiKey && !!this.eventSlug; + if (!this.configured) { + this.logger.warn( + 'ATTEND_API_KEY / ATTEND_EVENT_SLUG not set; Attend invites disabled', + ); + } + } + + private static readonly MAX_ATTEMPTS = 3; + private static readonly RETRY_DELAY_MS = 1000; + + /** + * Invites a participant to the Attend event by email. A 409 (already + * invited/registered) counts as success — the goal (an invite exists) + * already holds. Retries transient failures (network errors, non-409 + * non-2xx responses) up to MAX_ATTEMPTS times before giving up. Returns + * false only once every attempt has failed, or when unconfigured — the + * caller is responsible for surfacing that loudly, since a false here + * means a paying participant did not get invited. + */ + async inviteParticipant( + email: string, + name?: string | null, + ): Promise { + if (!this.configured) return false; + + const [firstName, ...rest] = (name ?? '') + .trim() + .split(/\s+/) + .filter(Boolean); + const lastName = rest.join(' '); + + let lastError = ''; + for (let attempt = 1; attempt <= AttendService.MAX_ATTEMPTS; attempt++) { + try { + const res = await fetchWithTimeout( + `${this.baseUrl}/api/v1/events/${this.eventSlug}/participants`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + ...(firstName ? { first_name: firstName } : {}), + ...(lastName ? { last_name: lastName } : {}), + }), + }, + ); + + if (res.ok || res.status === 409) return true; + + lastError = `HTTP ${res.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + + if (attempt < AttendService.MAX_ATTEMPTS) { + await new Promise((r) => + setTimeout(r, AttendService.RETRY_DELAY_MS * attempt), + ); + } + } + + this.logger.error( + `Attend invite failed for ${email} after ${AttendService.MAX_ATTEMPTS} attempts: ${lastError}`, + ); + return false; + } +} diff --git a/backend/src/entities/audit-log.entity.ts b/backend/src/entities/audit-log.entity.ts index 696c804..96533a8 100644 --- a/backend/src/entities/audit-log.entity.ts +++ b/backend/src/entities/audit-log.entity.ts @@ -40,6 +40,7 @@ export const AUDIT_ACTIONS = [ 'admin_fulfillment_message', 'sidekick_address_reveal', 'sidekick_user_note_change', + 'attend_invite_failed', ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/backend/src/shop/shop.module.ts b/backend/src/shop/shop.module.ts index eb4be06..8fa0fd4 100644 --- a/backend/src/shop/shop.module.ts +++ b/backend/src/shop/shop.module.ts @@ -4,6 +4,7 @@ import { AuthModule } from '../auth/auth.module'; import { AuditLogModule } from '../audit-log/audit-log.module'; import { RsvpModule } from '../rsvp/rsvp.module'; import { SlackModule } from '../slack/slack.module'; +import { AttendModule } from '../attend/attend.module'; import { ShopItem } from '../entities/shop-item.entity'; import { Order } from '../entities/order.entity'; import { FulfillmentUpdate } from '../entities/fulfillment-update.entity'; @@ -20,6 +21,7 @@ import { ShopService } from './shop.service'; AuditLogModule, RsvpModule, SlackModule, + AttendModule, ], controllers: [ShopController], providers: [ShopService], diff --git a/backend/src/shop/shop.service.ts b/backend/src/shop/shop.service.ts index 1e63d15..419a7ef 100644 --- a/backend/src/shop/shop.service.ts +++ b/backend/src/shop/shop.service.ts @@ -7,6 +7,7 @@ import { Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; import { Repository, DataSource, In } from 'typeorm'; import { ShopItem } from '../entities/shop-item.entity'; import { Project } from '../entities/project.entity'; @@ -18,6 +19,7 @@ import { ShopSuggestionVote } from '../entities/shop-suggestion-vote.entity'; import { AuditLogService } from '../audit-log/audit-log.service'; import { RsvpService } from '../rsvp/rsvp.service'; import { SlackNotifyService } from '../slack/slack-notify.service'; +import { AttendService } from '../attend/attend.service'; import { orderPendingDm, orderFulfilledDm, @@ -59,6 +61,8 @@ export class ShopService { private readonly auditLogService: AuditLogService, private readonly rsvpService: RsvpService, private readonly slackNotify: SlackNotifyService, + private readonly attendService: AttendService, + private readonly configService: ConfigService, ) {} /** Fire-and-forget order-status DM to the buyer (best-effort). */ @@ -76,6 +80,31 @@ export class ShopService { .catch(() => undefined); } + /** + * Surfaces an Attend invite failure loudly: an audit-log entry (searchable + * in the admin UI against the buyer) plus, if ATTEND_ALERT_SLACK_ID is set, + * a direct Slack DM so someone actually sees it instead of it sitting in a + * log file. + */ + private async alertAttendInviteFailure( + userId: string, + email: string, + ): Promise { + await this.auditLogService.log( + userId, + 'attend_invite_failed', + `Attend invite failed for ${email} — needs manual follow-up`, + ); + + const alertSlackId = this.configService.get('ATTEND_ALERT_SLACK_ID'); + if (alertSlackId) { + await this.slackNotify.dm( + alertSlackId, + `Attend invite failed for ${email} after retries — they bought a ticket but weren't invited. Needs manual invite.`, + ); + } + } + // ── Shop suggestions ── async listSuggestions(userId: string) { @@ -343,6 +372,23 @@ export class ShopService { if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestPurchasedItem'); }); + // Ticket item purchased — invite the buyer to the in-person event on Attend. + // A failed invite means a paying participant won't get into the event, so + // failures (after AttendService's own retries) must be surfaced loudly: + // an audit-log entry (visible/searchable in the admin UI) plus a direct + // Slack DM, rather than just a swallowed log line. + const ticketItemId = this.configService.get('ATTEND_TICKET_SHOP_ITEM_ID'); + if (ticketItemId && shopItemId === ticketItemId) { + this.userRepo + .findOne({ where: { id: userId }, select: ['email', 'name'] }) + .then(async (u) => { + if (!u?.email) return; + const invited = await this.attendService.inviteParticipant(u.email, u.name); + if (!invited) await this.alertAttendInviteFailure(userId, u.email); + }) + .catch(() => undefined); + } + this.notifyOrder( userId, orderPendingDm({