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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ the matching "confirm" endpoint accepts the signed XDR back.
| Method & path | Purpose |
|---|---|
| `GET /v1/users/me` | Current user's profile |
| `PATCH /v1/users/me` | Update the current user's display name |
| `PATCH /v1/users/me/wallet` | Link/update the caller's Stellar public key |
| `GET /v1/users/lookup` | Look up a user (e.g. by email) for transfers |

Expand All @@ -180,8 +181,9 @@ the matching "confirm" endpoint accepts the signed XDR back.
| `GET /v1/events` | Public marketplace listing |
| `GET /v1/events/:eventId` | Event detail |
| `GET /v1/organizations/:organizationId/events` | Events under an organization |
| `POST /v1/organizations/:organizationId/events` | Create a draft event |
| `POST /v1/organizations/:organizationId/events` | Create a draft event with description, image, and venue timezone |
| `POST /v1/events/:eventId/ticket-types` | Add a ticket type (name, price, quantity) to a draft event |
| `PATCH /v1/events/:eventId/ticket-types/:ticketTypeId` | Update ticket inventory without dropping below issued quantity |
| `POST /v1/events/:eventId/publish` | **build** — unsigned XDR for the on-chain `create_event` call |
| `POST /v1/events/:eventId/confirm-publish` | **confirm** — submits the signed XDR, sets `chainEventId` and `status: PUBLISHED` |

Expand Down
38 changes: 37 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,20 @@ curl -s -X PATCH "$API/v1/users/me/wallet" \

`409` when another account already uses that wallet.

### `PATCH /v1/users/me`

Updates the caller's display name and returns the updated profile.

| field | type | rules |
|---|---|---|
| `name` | string | required, 1 to 256 characters after trimming |

```bash
curl -s -X PATCH "$API/v1/users/me" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Ada Lovelace"}'
```

### `GET /v1/users/lookup?email=`

Resolves a recipient before an issue or transfer. `email` must be a
Expand Down Expand Up @@ -515,6 +529,9 @@ curl -s "$API/v1/events?q=jazz&category=CONCERTS&from=2026-11-01T00:00:00Z&to=20
{
"id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c",
"name": "Launch Night",
"description": "A night of live music at The Fillmore.",
"imageUrl": "https://cdn.example.com/launch-night.jpg",
"timezone": "America/Los_Angeles",
"status": "PUBLISHED",
"chainEventId": "42",
"publishedTxHash": "abababababababababababababababababababababababababababababababab",
Expand Down Expand Up @@ -589,8 +606,11 @@ Members only. Creates a `DRAFT` event.
| field | type | rules |
|---|---|---|
| `name` | string | required, 2 to 256 characters after trimming |
| `description` | string | required, 1 to 5000 characters after trimming |
| `imageUrl` | string or null | optional, absolute URL when provided |
| `category` | string | required, an `Industry` value |
| `venue` | string | required, 1 to 256 characters after trimming |
| `timezone` | string | required, valid IANA time-zone identifier |
| `startsAt` | date | required, not more than 60 seconds in the past |
| `endsAt` | date | optional |
| `maxResaleMultiplierBps` | integer | optional, 10000 to 50000, default 11000 (resale capped at 110% of face value) |
Expand All @@ -599,7 +619,7 @@ Members only. Creates a `DRAFT` event.
```bash
curl -s -X POST "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/events" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Launch Night","category":"CONCERTS","venue":"The Fillmore","startsAt":"2026-12-01T20:00:00.000Z","endsAt":"2026-12-01T23:00:00.000Z","maxResaleMultiplierBps":11000,"royaltyBps":500}'
-d '{"name":"Launch Night","description":"A night of live music at The Fillmore.","imageUrl":"https://cdn.example.com/launch-night.jpg","category":"CONCERTS","venue":"The Fillmore","timezone":"America/Los_Angeles","startsAt":"2026-12-01T20:00:00.000Z","endsAt":"2026-12-01T23:00:00.000Z","maxResaleMultiplierBps":11000,"royaltyBps":500}'
```

Returns the event row.
Expand Down Expand Up @@ -627,6 +647,22 @@ curl -s -X POST "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/ticket-type

Returns the ticket type row.

### `PATCH /v1/events/:eventId/ticket-types/:ticketTypeId`

Members only. Updates a ticket type's total inventory. The value may equal
`quantityIssued`, but cannot be lower; an attempted reduction below the
issued count returns `409`.

| field | type | rules |
|---|---|---|
| `quantityTotal` | integer | required, positive and at least `quantityIssued` |

```bash
curl -s -X PATCH "$API/v1/events/7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c/ticket-types/4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"quantityTotal":750}'
```

### `POST /v1/events/:eventId/publish`

Members only. Build step: reserves a `chainEventId` and returns the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Existing events predate these required display fields. Backfill them before
-- dropping the temporary defaults so all future writes must provide values.
ALTER TABLE "Event"
ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
ADD COLUMN "imageUrl" TEXT,
ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC';

ALTER TABLE "Event"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "timezone" DROP DEFAULT;
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,12 @@ model Event {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
name String
description String
imageUrl String?
category Industry
venue String
/// IANA time-zone identifier used to display UTC timestamps at the venue.
timezone String
startsAt DateTime
endsAt DateTime?
/// The `event_id` (u64) this event was registered under on the ticketing
Expand Down
3 changes: 3 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ async function main() {
data: {
organizationId: org.id,
name: 'StellarFest 2027',
description: 'A demo concert powered by StellarTickets.',
imageUrl: null,
category: Industry.CONCERTS,
venue: 'The Stellar Arena, Cape Town',
timezone: 'Africa/Johannesburg',
startsAt: new Date('2027-03-15T19:00:00Z'),
endsAt: new Date('2027-03-15T23:00:00Z'),
},
Expand Down
27 changes: 27 additions & 0 deletions src/common/decorators/is-iana-timezone.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { registerDecorator, ValidationOptions } from 'class-validator';

/** Validates an IANA time-zone identifier with the runtime's Intl database. */
export function IsIanaTimeZone(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isIanaTimeZone',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown): boolean {
if (typeof value !== 'string' || value.length === 0) return false;
try {
new Intl.DateTimeFormat('en-US', { timeZone: value }).format();
return true;
} catch {
return false;
}
},
defaultMessage(): string {
return '$property must be a valid IANA time zone';
},
},
});
};
}
22 changes: 22 additions & 0 deletions src/events/dto/create-event.dto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ const inMs = (offset: number) => new Date(Date.now() + offset).toISOString();
function build(overrides: Record<string, unknown> = {}) {
return plainToInstance(CreateEventDto, {
name: 'Radiohead Live',
description: 'A live performance.',
imageUrl: 'https://example.com/event.jpg',
category: 'CONCERTS',
venue: 'Amphitheater',
timezone: 'Europe/London',
startsAt: inMs(24 * 60 * 60 * 1000),
...overrides,
});
Expand All @@ -32,6 +35,25 @@ describe('CreateEventDto', () => {
expect(errors).toHaveLength(0);
});

it('accepts a null imageUrl', async () => {
const errors = await validate(build({ imageUrl: null }));
expect(errors).toHaveLength(0);
});

it('rejects an invalid imageUrl', async () => {
const errors = await validate(build({ imageUrl: 'not-a-url' }));
expect(errors.some((e) => e.property === 'imageUrl')).toBe(true);
});

it('accepts a valid IANA timezone and rejects an invalid one', async () => {
await expect(
validate(build({ timezone: 'Africa/Lagos' })),
).resolves.toHaveLength(0);

const errors = await validate(build({ timezone: 'Mars/Olympus_Mons' }));
expect(errors.some((e) => e.property === 'timezone')).toBe(true);
});

it('rejects a maxResaleMultiplierBps below 100% (would be a discount, not a cap)', async () => {
const errors = await validate(build({ maxResaleMultiplierBps: 9_000 }));
expect(errors.some((e) => e.property === 'maxResaleMultiplierBps')).toBe(
Expand Down
16 changes: 16 additions & 0 deletions src/events/dto/create-event.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IsInt,
IsOptional,
IsString,
IsUrl,
Max,
MaxLength,
Min,
Expand All @@ -16,6 +17,7 @@ import {
import { Industry } from '@prisma/client';
import { Trim } from '../../common/decorators/trim.decorator';
import { CreateEventTicketTypeDto } from './create-ticket-type.dto';
import { IsIanaTimeZone } from '../../common/decorators/is-iana-timezone.decorator';

/**
* How far in the past `startsAt` may be before it's rejected. Absorbs client
Expand All @@ -31,6 +33,16 @@ export class CreateEventDto {
@MaxLength(256)
name!: string;

@Trim()
@IsString()
@MinLength(1)
@MaxLength(5000)
description!: string;

@IsOptional()
@IsUrl({ require_protocol: true })
imageUrl?: string | null;

@IsEnum(Industry, {
message: `category must be one of: ${Object.values(Industry).join(', ')}`,
})
Expand All @@ -42,6 +54,10 @@ export class CreateEventDto {
@MaxLength(256)
venue!: string;

@Trim()
@IsIanaTimeZone()
timezone!: string;

@Type(() => Date)
@IsDate()
@MinDate(() => new Date(Date.now() - STARTS_AT_PAST_TOLERANCE_MS), {
Expand Down
7 changes: 7 additions & 0 deletions src/events/dto/update-ticket-type.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsInt, IsPositive } from 'class-validator';

export class UpdateTicketTypeDto {
@IsInt()
@IsPositive()
quantityTotal!: number;
}
54 changes: 53 additions & 1 deletion src/events/events.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HEADERS_METADATA } from '@nestjs/common/constants';
import {
ConflictException,
ForbiddenException,
INestApplication,
NotFoundException,
Expand All @@ -26,6 +27,7 @@ describe('EventsController', () => {
summarizeForOrganization: jest.fn(),
create: jest.fn(),
addTicketType: jest.fn(),
updateTicketType: jest.fn(),
buildPublishTx: jest.fn(),
unpublish: jest.fn(),
confirmPublish: jest.fn(),
Expand Down Expand Up @@ -118,7 +120,6 @@ describe('EventsController', () => {
false,
undefined,
);
expect(service.getWithReconciliation).toHaveBeenCalledWith('e1', false);

service.getWithReconciliation.mockRejectedValueOnce(
new NotFoundException('Event not found'),
Expand Down Expand Up @@ -160,6 +161,7 @@ describe('EventsController', () => {
['get', '/organizations/o1/events'],
['post', '/organizations/o1/events'],
['post', '/events/e1/ticket-types'],
['patch', '/events/e1/ticket-types/t1'],
['post', '/events/e1/publish'],
['post', '/events/e1/unpublish'],
['post', '/events/e1/confirm-publish'],
Expand Down Expand Up @@ -235,8 +237,11 @@ describe('EventsController', () => {
describe('POST /organizations/:organizationId/events', () => {
const body = {
name: 'Concert',
description: 'An evening concert.',
imageUrl: 'https://example.com/concert.jpg',
category: 'CONCERTS',
venue: 'Main Hall',
timezone: 'Africa/Lagos',
startsAt: new Date(Date.now() + 86_400_000).toISOString(),
};

Expand All @@ -259,6 +264,8 @@ describe('EventsController', () => {
['an unknown category', { category: 'NOPE' }],
['a start date in the past', { startsAt: '2020-01-01T00:00:00.000Z' }],
['an out-of-range royalty', { royaltyBps: 5000 }],
['an invalid image URL', { imageUrl: 'not-a-url' }],
['an invalid timezone', { timezone: 'Lagos' }],
['an unknown field', { bogus: true }],
])('rejects %s', async (_label, patch) => {
await http()
Expand Down Expand Up @@ -311,6 +318,51 @@ describe('EventsController', () => {
});
});

describe('PATCH /events/:eventId/ticket-types/:ticketTypeId', () => {
it('updates total inventory for the caller', async () => {
service.updateTicketType.mockResolvedValue({
id: 't1',
quantityTotal: 75,
});

await http()
.patch('/events/e1/ticket-types/t1')
.set(AUTH)
.send({ quantityTotal: 75 })
.expect(200);

expect(service.updateTicketType).toHaveBeenCalledWith(
TEST_USER.userId,
'e1',
't1',
expect.objectContaining({ quantityTotal: 75 }),
);
});

it('rejects invalid totals and propagates inventory conflicts', async () => {
await http()
.patch('/events/e1/ticket-types/t1')
.set(AUTH)
.send({ quantityTotal: 0 })
.expect(400);
expect(service.updateTicketType).not.toHaveBeenCalled();

service.updateTicketType.mockRejectedValue(
new ConflictException(
'quantityTotal cannot be lower than quantityIssued',
),
);
const res = await http()
.patch('/events/e1/ticket-types/t1')
.set(AUTH)
.send({ quantityTotal: 9 })
.expect(409);
expect((res.body as { message: string }).message).toBe(
'quantityTotal cannot be lower than quantityIssued',
);
});
});

it('POST /events/:eventId/publish builds the publish transaction', async () => {
service.buildPublishTx.mockResolvedValue({ xdr: 'x' });
const res = await http().post('/events/e1/publish').set(AUTH).expect(201);
Expand Down
18 changes: 18 additions & 0 deletions src/events/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Get,
Header,
Param,
Patch,
Post,
Query,
UseGuards,
Expand All @@ -17,6 +18,7 @@ import type { CurrentUserPayload } from '../auth/decorators/current-user.decorat
import { EventsService } from './events.service';
import { CreateEventDto } from './dto/create-event.dto';
import { CreateTicketTypeDto } from './dto/create-ticket-type.dto';
import { UpdateTicketTypeDto } from './dto/update-ticket-type.dto';
import { ConfirmPublishDto } from './dto/confirm-publish.dto';
import { PaginatedResponseInterceptor } from '../common/interceptors/paginated-response.interceptor';
import { ListOrganizationEventsQueryDto } from './dto/list-organization-events-query.dto';
Expand Down Expand Up @@ -96,6 +98,22 @@ export class EventsController {
return this.eventsService.addTicketType(user.userId, eventId, dto);
}

@Patch('events/:eventId/ticket-types/:ticketTypeId')
@UseGuards(JwtAuthGuard)
updateTicketType(
@CurrentUser() user: CurrentUserPayload,
@Param('eventId') eventId: string,
@Param('ticketTypeId') ticketTypeId: string,
@Body() dto: UpdateTicketTypeDto,
) {
return this.eventsService.updateTicketType(
user.userId,
eventId,
ticketTypeId,
dto,
);
}

@Post('events/:eventId/publish')
@UseGuards(JwtAuthGuard)
buildPublishTx(
Expand Down
Loading