From 41d120fd61741ffda151e8cf35234837c5640ed0 Mon Sep 17 00:00:00 2001 From: Abdulhameed-123 Date: Sun, 18 Oct 2026 13:36:09 +0100 Subject: [PATCH] feat: complete assigned profile and event issues --- README.md | 4 +- docs/API.md | 38 +++++++- .../migration.sql | 10 ++ prisma/schema.prisma | 4 + prisma/seed.ts | 3 + .../decorators/is-iana-timezone.decorator.ts | 27 ++++++ src/events/dto/create-event.dto.spec.ts | 22 +++++ src/events/dto/create-event.dto.ts | 16 ++++ src/events/dto/update-ticket-type.dto.ts | 7 ++ src/events/events.controller.spec.ts | 54 ++++++++++- src/events/events.controller.ts | 18 ++++ src/events/events.service.spec.ts | 93 ++++++++++++++++++- src/events/events.service.ts | 45 +++++++++ src/users/dto/update-profile.dto.ts | 10 ++ src/users/users.controller.spec.ts | 35 +++++++ src/users/users.controller.ts | 9 ++ src/users/users.service.spec.ts | 38 ++++++++ src/users/users.service.ts | 25 +++++ test/events.e2e-spec.ts | 3 + test/factories/event.factory.ts | 3 + 20 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20261022000000_event_details_timezone/migration.sql create mode 100644 src/common/decorators/is-iana-timezone.decorator.ts create mode 100644 src/events/dto/update-ticket-type.dto.ts create mode 100644 src/users/dto/update-profile.dto.ts diff --git a/README.md b/README.md index b3694d0..232bbbb 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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` | diff --git a/docs/API.md b/docs/API.md index 73d8558..290e10b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 @@ -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", @@ -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) | @@ -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. @@ -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 diff --git a/prisma/migrations/20261022000000_event_details_timezone/migration.sql b/prisma/migrations/20261022000000_event_details_timezone/migration.sql new file mode 100644 index 0000000..eeead57 --- /dev/null +++ b/prisma/migrations/20261022000000_event_details_timezone/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ff5aa1..fc7c9cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/prisma/seed.ts b/prisma/seed.ts index b284f14..e202a14 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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'), }, diff --git a/src/common/decorators/is-iana-timezone.decorator.ts b/src/common/decorators/is-iana-timezone.decorator.ts new file mode 100644 index 0000000..cf9251f --- /dev/null +++ b/src/common/decorators/is-iana-timezone.decorator.ts @@ -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'; + }, + }, + }); + }; +} diff --git a/src/events/dto/create-event.dto.spec.ts b/src/events/dto/create-event.dto.spec.ts index 6631545..f662c80 100644 --- a/src/events/dto/create-event.dto.spec.ts +++ b/src/events/dto/create-event.dto.spec.ts @@ -12,8 +12,11 @@ const inMs = (offset: number) => new Date(Date.now() + offset).toISOString(); function build(overrides: Record = {}) { 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, }); @@ -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( diff --git a/src/events/dto/create-event.dto.ts b/src/events/dto/create-event.dto.ts index 97338f2..cef98a0 100644 --- a/src/events/dto/create-event.dto.ts +++ b/src/events/dto/create-event.dto.ts @@ -6,6 +6,7 @@ import { IsInt, IsOptional, IsString, + IsUrl, Max, MaxLength, Min, @@ -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 @@ -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(', ')}`, }) @@ -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), { diff --git a/src/events/dto/update-ticket-type.dto.ts b/src/events/dto/update-ticket-type.dto.ts new file mode 100644 index 0000000..3077064 --- /dev/null +++ b/src/events/dto/update-ticket-type.dto.ts @@ -0,0 +1,7 @@ +import { IsInt, IsPositive } from 'class-validator'; + +export class UpdateTicketTypeDto { + @IsInt() + @IsPositive() + quantityTotal!: number; +} diff --git a/src/events/events.controller.spec.ts b/src/events/events.controller.spec.ts index 57b9d59..a6d56e7 100644 --- a/src/events/events.controller.spec.ts +++ b/src/events/events.controller.spec.ts @@ -1,5 +1,6 @@ import { HEADERS_METADATA } from '@nestjs/common/constants'; import { + ConflictException, ForbiddenException, INestApplication, NotFoundException, @@ -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(), @@ -118,7 +120,6 @@ describe('EventsController', () => { false, undefined, ); - expect(service.getWithReconciliation).toHaveBeenCalledWith('e1', false); service.getWithReconciliation.mockRejectedValueOnce( new NotFoundException('Event not found'), @@ -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'], @@ -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(), }; @@ -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() @@ -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); diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index 921e100..1de7aea 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -5,6 +5,7 @@ import { Get, Header, Param, + Patch, Post, Query, UseGuards, @@ -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'; @@ -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( diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts index 7d67239..ca2a22a 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -30,7 +30,14 @@ describe('EventsService', () => { groupBy: jest.Mock; }; ticket: { count: jest.Mock }; - ticketType: { count: jest.Mock; createMany: jest.Mock; create: jest.Mock }; + ticketType: { + count: jest.Mock; + createMany: jest.Mock; + create: jest.Mock; + findFirst: jest.Mock; + updateMany: jest.Mock; + findUniqueOrThrow: jest.Mock; + }; }; let organizations: { assertMember: jest.Mock; @@ -68,6 +75,9 @@ describe('EventsService', () => { count: jest.fn().mockResolvedValue(1), createMany: jest.fn(), create: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + findUniqueOrThrow: jest.fn(), }, }; organizations = { @@ -125,14 +135,20 @@ describe('EventsService', () => { await service.create('organizer-1', 'org-1', { name: 'Radiohead Live', + description: 'A live performance.', + imageUrl: 'https://example.com/radiohead.jpg', category: 'CONCERTS', venue: 'O2 Arena', + timezone: 'Europe/London', startsAt: new Date(), } as never); expect(prisma.event.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ + description: 'A live performance.', + imageUrl: 'https://example.com/radiohead.jpg', + timezone: 'Europe/London', maxResaleMultiplierBps: 11_000, royaltyBps: 500, }) as never, @@ -234,7 +250,10 @@ describe('EventsService', () => { }), organization: createOrganization({ stellarAccount: 'GORG' }), }); - prisma.ticketType.create.mockResolvedValue({ id: 'tt-1' }); + prisma.ticketType.create.mockResolvedValue({ + id: 'tt-1', + price: 1_000n, + }); await service.addTicketType('organizer-1', 'event-1', dto); @@ -297,6 +316,74 @@ describe('EventsService', () => { }); }); + describe('updateTicketType (#101)', () => { + beforeEach(() => { + prisma.ticketType.findFirst.mockResolvedValue({ + id: 'tt-1', + eventId: 'event-1', + quantityIssued: 10, + event: { organizationId: 'org-1' }, + }); + prisma.ticketType.findUniqueOrThrow.mockResolvedValue({ + id: 'tt-1', + price: 1_000n, + quantityIssued: 10, + quantityTotal: 10, + }); + }); + + it('allows quantityTotal to equal quantityIssued', async () => { + prisma.ticketType.updateMany.mockResolvedValue({ count: 1 }); + + const result = await service.updateTicketType( + 'organizer-1', + 'event-1', + 'tt-1', + { quantityTotal: 10 }, + ); + + expect(prisma.ticketType.updateMany).toHaveBeenCalledWith({ + where: { + id: 'tt-1', + eventId: 'event-1', + quantityIssued: { lte: 10 }, + }, + data: { quantityTotal: 10 }, + }); + expect(result.quantityTotal).toBe(10); + }); + + it('returns 409 when quantityTotal would be below quantityIssued', async () => { + prisma.ticketType.updateMany.mockResolvedValue({ count: 0 }); + + const attempt = service.updateTicketType( + 'organizer-1', + 'event-1', + 'tt-1', + { quantityTotal: 9 }, + ); + + await expect(attempt).rejects.toBeInstanceOf(ConflictException); + await expect(attempt).rejects.toMatchObject({ + message: 'quantityTotal cannot be lower than quantityIssued', + status: 409, + }); + expect(prisma.ticketType.findUniqueOrThrow).not.toHaveBeenCalled(); + }); + + it('returns not-found when the ticket type is outside the event', async () => { + prisma.ticketType.findFirst.mockResolvedValue(null); + + await expect( + service.updateTicketType('organizer-1', 'event-1', 'tt-other', { + quantityTotal: 10, + }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(organizations.assertMember).not.toHaveBeenCalled(); + expect(prisma.ticketType.updateMany).not.toHaveBeenCalled(); + }); + }); + describe('buildPublishTx', () => { it('refuses to publish an already-published event', async () => { prisma.event.findUnique.mockResolvedValue({ @@ -684,7 +771,7 @@ describe('EventsService', () => { 'organizer-1', ); - expect(event).toBe(draft); + expect(event).toMatchObject(draft); }); it('shows a published event to anonymous callers without a membership lookup', async () => { diff --git a/src/events/events.service.ts b/src/events/events.service.ts index f50b76d..659ac57 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -17,6 +17,7 @@ import { toSkipTake } from '../common/pagination/paginated'; import { CreateEventDto } from './dto/create-event.dto'; import { ListPublishedEventsQueryDto } from './dto/list-published-events-query.dto'; import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; +import { UpdateTicketTypeDto } from './dto/update-ticket-type.dto'; import { CACHE_STORE } from '../common/cache/cache-store'; import { Inject } from '@nestjs/common'; import { formatPrice } from '../common/utils/currency'; @@ -69,8 +70,11 @@ export class EventsService { data: { organizationId, name: dto.name, + description: dto.description, + imageUrl: dto.imageUrl, category: dto.category, venue: dto.venue, + timezone: dto.timezone, startsAt: dto.startsAt, endsAt: dto.endsAt, maxResaleMultiplierBps: @@ -138,6 +142,47 @@ export class EventsService { return this.withTicketTypePricing(ticketType); } + async updateTicketType( + userId: string, + eventId: string, + ticketTypeId: string, + dto: UpdateTicketTypeDto, + ) { + const ticketType = await this.prisma.ticketType.findFirst({ + where: { id: ticketTypeId, eventId }, + include: { event: { select: { organizationId: true } } }, + }); + if (!ticketType) { + throw new NotFoundException('Ticket type not found'); + } + await this.organizations.assertMember( + ticketType.event.organizationId, + userId, + ); + + // Keep the comparison and write in one statement so concurrent issuance + // cannot make quantityTotal smaller than quantityIssued between them. + const { count } = await this.prisma.ticketType.updateMany({ + where: { + id: ticketTypeId, + eventId, + quantityIssued: { lte: dto.quantityTotal }, + }, + data: { quantityTotal: dto.quantityTotal }, + }); + if (count === 0) { + throw new ConflictException( + 'quantityTotal cannot be lower than quantityIssued', + ); + } + + const updated = await this.prisma.ticketType.findUniqueOrThrow({ + where: { id: ticketTypeId }, + }); + await this.invalidateCache(); + return this.withTicketTypePricing(updated); + } + /** Step 1 of publishing: returns an unsigned XDR for the organizer's wallet to sign. */ async buildPublishTx( userId: string, diff --git a/src/users/dto/update-profile.dto.ts b/src/users/dto/update-profile.dto.ts new file mode 100644 index 0000000..5a9788a --- /dev/null +++ b/src/users/dto/update-profile.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { Trim } from '../../common/decorators/trim.decorator'; + +export class UpdateProfileDto { + @Trim() + @IsString() + @MinLength(1) + @MaxLength(256) + name!: string; +} diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index 498e393..e5c14f1 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -19,6 +19,7 @@ describe('UsersController', () => { let app: INestApplication; const service = { findMe: jest.fn(), + updateProfile: jest.fn(), connectWallet: jest.fn(), lookupByEmail: jest.fn(), }; @@ -34,6 +35,7 @@ describe('UsersController', () => { it.each([ ['get', '/users/me'], + ['patch', '/users/me'], ['patch', '/users/me/wallet'], ['get', '/users/lookup?email=a@b.co'], ] as const)('rejects unauthenticated %s %s', async (method, url) => { @@ -55,6 +57,39 @@ describe('UsersController', () => { await request(server(app)).get('/users/me').set(AUTH).expect(404); }); + it('PATCH /me trims and updates the current user name', async () => { + service.updateProfile.mockResolvedValue({ + id: TEST_USER.userId, + name: 'Ada Lovelace', + }); + + const res = await request(server(app)) + .patch('/users/me') + .set(AUTH) + .send({ name: ' Ada Lovelace ' }) + .expect(200); + + expect((res.body as { name: string }).name).toBe('Ada Lovelace'); + expect(service.updateProfile).toHaveBeenCalledWith( + TEST_USER.userId, + 'Ada Lovelace', + ); + }); + + it.each([ + ['an empty name', { name: ' ' }], + ['a name over 256 characters', { name: 'a'.repeat(257) }], + ['a missing name', {}], + ['an unknown field', { name: 'Ada', email: 'new@example.com' }], + ])('PATCH /me rejects %s', async (_label, body) => { + await request(server(app)) + .patch('/users/me') + .set(AUTH) + .send(body) + .expect(400); + expect(service.updateProfile).not.toHaveBeenCalled(); + }); + it('PATCH /me/wallet connects a valid wallet', async () => { service.connectWallet.mockResolvedValue({ id: TEST_USER.userId, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 3b01457..f1effbe 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -5,6 +5,7 @@ import type { CurrentUserPayload } from '../auth/decorators/current-user.decorat import { UsersService } from './users.service'; import { ConnectWalletDto } from './dto/connect-wallet.dto'; import { LookupQuery } from './dto/lookup-query.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; @Controller('users') @UseGuards(JwtAuthGuard) @@ -16,6 +17,14 @@ export class UsersController { return this.usersService.findMe(user.userId); } + @Patch('me') + updateProfile( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: UpdateProfileDto, + ) { + return this.usersService.updateProfile(user.userId, dto.name); + } + @Patch('me/wallet') connectWallet( @CurrentUser() user: CurrentUserPayload, diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index c2c4a4e..26fe5d5 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -108,6 +108,44 @@ describe('UsersService', () => { }); }); + describe('updateProfile', () => { + it('updates and returns the profile projection', async () => { + prisma.user.update.mockResolvedValue({ + id: 'user-1', + name: 'Ada Lovelace', + }); + + await expect( + service.updateProfile('user-1', 'Ada Lovelace'), + ).resolves.toEqual({ id: 'user-1', name: 'Ada Lovelace' }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { name: 'Ada Lovelace' }, + select: { + id: true, + email: true, + name: true, + role: true, + stellarPublicKey: true, + createdAt: true, + }, + }); + }); + + it('returns not-found when the account no longer exists', async () => { + prisma.user.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Record not found', { + code: 'P2025', + clientVersion: '6.19.3', + }), + ); + + await expect( + service.updateProfile('missing', 'Ada Lovelace'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + describe('lookupByEmail', () => { it('throws NotFoundException when no account matches', async () => { prisma.user.findUnique.mockResolvedValue(null); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 34034ac..e82d6f3 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -28,6 +28,31 @@ export class UsersService { return user; } + async updateProfile(userId: string, name: string) { + try { + return await this.prisma.user.update({ + where: { id: userId }, + data: { name }, + select: { + id: true, + email: true, + name: true, + role: true, + stellarPublicKey: true, + createdAt: true, + }, + }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new NotFoundException('User not found'); + } + throw err; + } + } + async connectWallet(userId: string, stellarPublicKey: string) { try { const [user, ticketCount, activeListingCount] = await Promise.all([ diff --git a/test/events.e2e-spec.ts b/test/events.e2e-spec.ts index fd2d899..6923c3c 100644 --- a/test/events.e2e-spec.ts +++ b/test/events.e2e-spec.ts @@ -116,8 +116,11 @@ describe('Event lifecycle (e2e)', () => { .post(`/v1/organizations/${organizationId}/events`) .send({ name: `Test Concert ${randomUUID()}`, + description: 'A test concert.', + imageUrl: 'https://example.com/test-concert.jpg', category: 'CONCERTS', venue: 'Test Arena', + timezone: 'UTC', startsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }) .expect(201); diff --git a/test/factories/event.factory.ts b/test/factories/event.factory.ts index cbdac65..787462a 100644 --- a/test/factories/event.factory.ts +++ b/test/factories/event.factory.ts @@ -10,8 +10,11 @@ export function createEvent(overrides: Partial = {}): Event { id: `event-${n}`, organizationId: `org-${n}`, name: `Test Event ${n}`, + description: `Description for test event ${n}`, + imageUrl: null, category: 'CONCERTS', venue: 'Madison Square Garden', + timezone: 'America/New_York', startsAt: new Date('2026-12-01T20:00:00.000Z'), endsAt: new Date('2026-12-01T23:00:00.000Z'), chainEventId: null,