Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
8 changes: 8 additions & 0 deletions backend/src/attend/attend.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AttendService } from './attend.service';

@Module({
providers: [AttendService],
exports: [AttendService],
})
export class AttendModule {}
129 changes: 129 additions & 0 deletions backend/src/attend/attend.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>): 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();
});
});
95 changes: 95 additions & 0 deletions backend/src/attend/attend.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
1 change: 1 addition & 0 deletions backend/src/entities/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
2 changes: 2 additions & 0 deletions backend/src/shop/shop.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ import { ShopService } from './shop.service';
AuditLogModule,
RsvpModule,
SlackModule,
AttendModule,
],
controllers: [ShopController],
providers: [ShopService],
Expand Down
46 changes: 46 additions & 0 deletions backend/src/shop/shop.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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). */
Expand All @@ -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<void> {
await this.auditLogService.log(
userId,
'attend_invite_failed',
`Attend invite failed for ${email} — needs manual follow-up`,
);

const alertSlackId = this.configService.get<string>('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) {
Expand Down Expand Up @@ -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<string>('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({
Expand Down
Loading