From cd15bef08dcf295379106ca09258d1f0fcccf2f8 Mon Sep 17 00:00:00 2001 From: savikthk Date: Sun, 27 Sep 2026 23:26:05 +0300 Subject: [PATCH] fix(deals): end hot deals when the venue closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deal whose end time was later than the venue closing stayed visible after closing: the guest link showed the card with "До 22:50", the old "Забронировать" button answered that the venue is closed, and the cabinet kept counting the deal as live. A deal is now over at its end time or at the first closing after its start, whichever comes first, and a deal that started while the venue was closed is over right away. Round the clock venues keep the end time. The rule lives in dealOverAt and in the same SQL condition of the deals repository, so guest cards, the /eat pick, the venue list counter, the cabinet list and status, the one live deal per item rule and booking all agree. Deals already stored with an end after closing are handled by this condition, no data migration. Booking such a deal now answers deal_not_active ("Предложение уже закончилось") instead of venue_closed. --- backend/src/domain/deals.test.ts | 35 +++++++++++++ backend/src/domain/deals.ts | 13 +++++ backend/src/http/routes/bookings.ts | 2 +- backend/src/http/schemas/deals.ts | 2 +- backend/src/repositories/deals.ts | 42 +++++++++++----- backend/src/services/bookings.int.test.ts | 16 +++++- backend/src/services/bookings.ts | 18 +++++-- backend/src/services/catalog.int.test.ts | 60 +++++++++++++++++++++++ backend/src/services/deals.int.test.ts | 22 +++++++++ backend/src/services/deals.test.ts | 25 +++++++--- backend/src/services/deals.ts | 29 ++++++++--- backend/src/services/recommendations.ts | 2 +- backend/test/venue-bot.ts | 11 ++++- docs/scenario.md | 2 +- openapi.yaml | 20 ++++---- 15 files changed, 250 insertions(+), 49 deletions(-) create mode 100644 backend/src/domain/deals.test.ts create mode 100644 backend/src/domain/deals.ts diff --git a/backend/src/domain/deals.test.ts b/backend/src/domain/deals.test.ts new file mode 100644 index 0000000..9ffd609 --- /dev/null +++ b/backend/src/domain/deals.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { dealOverAt } from './deals.ts'; + +const at = (iso: string) => new Date(iso); +const ZERNO = { opensAt: '08:00', closesAt: '22:00', timezone: 'Europe/Moscow' }; + +describe('dealOverAt', () => { + it('keeps the end of a deal that ends before the closing', () => { + const deal = { startsAt: at('2026-09-27T15:00:00Z'), endsAt: at('2026-09-27T17:00:00Z') }; + expect(dealOverAt(deal, ZERNO)).toEqual(deal.endsAt); + }); + + it('ends a deal at the closing when its end time is later', () => { + const deal = { startsAt: at('2026-09-27T18:48:00Z'), endsAt: at('2026-09-27T19:50:00Z') }; + expect(dealOverAt(deal, ZERNO)).toEqual(at('2026-09-27T19:00:00Z')); + }); + + it('ends a deal that starts while the venue is closed right at its start', () => { + const afterClosing = { startsAt: at('2026-09-27T19:30:00Z'), endsAt: at('2026-09-27T20:30:00Z') }; + expect(dealOverAt(afterClosing, ZERNO)).toEqual(afterClosing.startsAt); + const beforeOpening = { startsAt: at('2026-09-27T04:00:00Z'), endsAt: at('2026-09-27T19:00:00Z') }; + expect(dealOverAt(beforeOpening, ZERNO)).toEqual(beforeOpening.startsAt); + }); + + it('ends at a closing after midnight', () => { + const deal = { startsAt: at('2026-09-27T20:30:00Z'), endsAt: at('2026-09-28T01:00:00Z') }; + const bar = { ...ZERNO, opensAt: '18:00', closesAt: '02:00' }; + expect(dealOverAt(deal, bar)).toEqual(at('2026-09-27T23:00:00Z')); + }); + + it('has no closing round the clock', () => { + const deal = { startsAt: at('2026-09-27T21:30:00Z'), endsAt: at('2026-09-28T20:00:00Z') }; + expect(dealOverAt(deal, { ...ZERNO, opensAt: '00:00', closesAt: '00:00' })).toEqual(deal.endsAt); + }); +}); diff --git a/backend/src/domain/deals.ts b/backend/src/domain/deals.ts new file mode 100644 index 0000000..75857e8 --- /dev/null +++ b/backend/src/domain/deals.ts @@ -0,0 +1,13 @@ +import { isOpenAt, nextClosingAt } from '../shared/time.ts'; +import type { Deal, Venue } from './models.ts'; + +export function dealOverAt( + deal: Pick, + venue: Pick, +): Date { + const { opensAt, closesAt, timezone } = venue; + const closing = nextClosingAt(opensAt, closesAt, deal.startsAt, timezone); + if (!closing) return deal.endsAt; + if (!isOpenAt(opensAt, closesAt, deal.startsAt, timezone)) return deal.startsAt; + return closing < deal.endsAt ? closing : deal.endsAt; +} diff --git a/backend/src/http/routes/bookings.ts b/backend/src/http/routes/bookings.ts index d00ecbb..0329557 100644 --- a/backend/src/http/routes/bookings.ts +++ b/backend/src/http/routes/bookings.ts @@ -43,7 +43,7 @@ export const bookingsRoutes: FastifyPluginCallbackZod<{ bookings: BookingsServic 'deal_not_found (404): горящего предложения нет или оно на другую позицию.', 'offer_not_found (404): предложение показано другому гостю или на другую позицию, бронируйте без offerId.', 'venue_closed (409): заведение сейчас закрыто, покажите часы работы.', - 'deal_not_active (409): горящее предложение снято, закончилось или ещё не началось, предложите бронь по цене меню без dealId.', + 'deal_not_active (409): горящее предложение снято, закончилось (в том числе потому что заведение закрылось) или ещё не началось, предложите бронь по цене меню без dealId.', 'booking_exists (409): у гостя уже есть активная бронь этой позиции, откройте её из GET /api/v1/bookings.', 'deal_sold_out (409): порции по предложению закончились, предложите бронь по цене меню без dealId.', `too_many_bookings (409): уже ${MAX_ACTIVE_BOOKINGS} активные брони, отмените одну из них или дождитесь, пока заведение её погасит.`, diff --git a/backend/src/http/schemas/deals.ts b/backend/src/http/schemas/deals.ts index 69c802c..c469d53 100644 --- a/backend/src/http/schemas/deals.ts +++ b/backend/src/http/schemas/deals.ts @@ -17,7 +17,7 @@ export const DealSchema = z status: z .enum(DEAL_STATUSES) .describe( - 'active - продаётся; scheduled - ещё не началась; sold_out - порции закончились; ended - время вышло; cancelled - снята заведением', + 'active - продаётся; scheduled - ещё не началась; sold_out - порции закончились; ended - время вышло или заведение закрылось после начала предложения; cancelled - снята заведением', ), }) .meta({ diff --git a/backend/src/repositories/deals.ts b/backend/src/repositories/deals.ts index 4664da9..0a360ef 100644 --- a/backend/src/repositories/deals.ts +++ b/backend/src/repositories/deals.ts @@ -19,8 +19,24 @@ const DEAL_COLUMNS = `d.id, d.venue_id, d.menu_item_id, d.price_rub, d.quantity_ const LIVE = 'd.cancelled_at is null and d.quantity_left > 0'; +const LOCAL_START = '(d.starts_at at time zone v.timezone)'; + +const OPEN_AT_START = `(v.opens_at < v.closes_at and ${LOCAL_START}::time >= v.opens_at and ${LOCAL_START}::time < v.closes_at + or v.opens_at > v.closes_at and (${LOCAL_START}::time >= v.opens_at or ${LOCAL_START}::time < v.closes_at))`; + +const CLOSING_AFTER_START = `((${LOCAL_START}::date + v.closes_at + + case when ${LOCAL_START}::time < v.closes_at then interval '0 days' else interval '1 day' end) at time zone v.timezone)`; + +const OVER_AT = `case + when v.opens_at = v.closes_at then d.ends_at + when ${OPEN_AT_START} then least(d.ends_at, ${CLOSING_AFTER_START}) + else d.starts_at + end`; + +const WITH_VENUE = 'join venues v on v.id = d.venue_id'; + function visibleTo(nowParam: string): string { - return `${LIVE} and d.starts_at <= ${nowParam} and d.ends_at > ${nowParam} + return `${LIVE} and d.starts_at <= ${nowParam} and ${OVER_AT} > ${nowParam} and m.archived_at is null and m.is_available`; } @@ -107,7 +123,8 @@ export async function cancelLiveInVenue( ): Promise { await db.query( `update deals as d set cancelled_at = $3 - where d.venue_id = $1 and d.id = $2 and ${LIVE} and d.ends_at > $3`, + from venues v + where v.id = d.venue_id and d.venue_id = $1 and d.id = $2 and ${LIVE} and ${OVER_AT} > $3`, [venueId, id, now], ); } @@ -115,7 +132,8 @@ export async function cancelLiveInVenue( export async function cancelLiveForItem(db: Queryable, menuItemId: number, now: Date): Promise { await db.query( `update deals as d set cancelled_at = $2 - where d.menu_item_id = $1 and ${LIVE} and d.ends_at > $2`, + from venues v + where v.id = d.venue_id and d.menu_item_id = $1 and ${LIVE} and ${OVER_AT} > $2`, [menuItemId, now], ); } @@ -141,8 +159,8 @@ export async function lockInVenue(db: Queryable, venueId: number, id: number): P export async function findLiveForItem(db: Queryable, menuItemId: number, now: Date): Promise { const row = await maybeOne( db, - `select ${DEAL_COLUMNS} from deals d - where d.menu_item_id = $1 and ${LIVE} and d.ends_at > $2 + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} + where d.menu_item_id = $1 and ${LIVE} and ${OVER_AT} > $2 order by d.id limit 1`, [menuItemId, now], @@ -152,8 +170,8 @@ export async function findLiveForItem(db: Queryable, menuItemId: number, now: Da export async function listLive(db: Queryable, venueId: number, now: Date): Promise { const { rows } = await db.query( - `select ${DEAL_COLUMNS} from deals d - where d.venue_id = $1 and ${LIVE} and d.ends_at > $2 + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} + where d.venue_id = $1 and ${LIVE} and ${OVER_AT} > $2 order by d.ends_at, d.id`, [venueId, now], ); @@ -162,9 +180,9 @@ export async function listLive(db: Queryable, venueId: number, now: Date): Promi export async function listFinished(db: Queryable, venueId: number, now: Date, since: Date): Promise { const { rows } = await db.query( - `select ${DEAL_COLUMNS} from deals d + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} where d.venue_id = $1 and d.created_at >= $3 - and (d.cancelled_at is not null or d.quantity_left = 0 or d.ends_at <= $2) + and (d.cancelled_at is not null or d.quantity_left = 0 or ${OVER_AT} <= $2) order by d.ends_at desc, d.id desc`, [venueId, now, since], ); @@ -174,7 +192,7 @@ export async function listFinished(db: Queryable, venueId: number, now: Date, si export async function listVisible(db: Queryable, venueIds: readonly number[], now: Date): Promise { if (venueIds.length === 0) return []; const { rows } = await db.query( - `select ${DEAL_COLUMNS} from deals d + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} join menu_items m on m.id = d.menu_item_id where d.venue_id = any($1::bigint[]) and ${visibleTo('$2')} order by d.ends_at, d.id`, @@ -186,7 +204,7 @@ export async function listVisible(db: Queryable, venueIds: readonly number[], no export async function findVisible(db: Queryable, id: number, now: Date): Promise { const row = await maybeOne( db, - `select ${DEAL_COLUMNS} from deals d + `select ${DEAL_COLUMNS} from deals d ${WITH_VENUE} join menu_items m on m.id = d.menu_item_id where d.id = $1 and ${visibleTo('$2')}`, [id, now], @@ -201,7 +219,7 @@ export async function countVisibleByVenue( ): Promise> { if (venueIds.length === 0) return new Map(); const { rows } = await db.query<{ venue_id: number; deals: number }>( - `select d.venue_id, count(*) as deals from deals d + `select d.venue_id, count(*) as deals from deals d ${WITH_VENUE} join menu_items m on m.id = d.menu_item_id where d.venue_id = any($1::bigint[]) and ${visibleTo('$2')} group by d.venue_id`, diff --git a/backend/src/services/bookings.int.test.ts b/backend/src/services/bookings.int.test.ts index 2406897..5b94f61 100644 --- a/backend/src/services/bookings.int.test.ts +++ b/backend/src/services/bookings.int.test.ts @@ -208,10 +208,24 @@ describe('creating bookings', () => { code: 'menu_item_not_found', }); clock.set('2026-09-25T19:00:00Z'); - await expect(service.create(GUEST, { menuItemId: eclair.id, dealId: deal.id })).rejects.toMatchObject({ + await expect(service.create(GUEST, { menuItemId: eclair.id })).rejects.toMatchObject({ status: 409, code: 'venue_closed', }); + }); + + it('says that a deal is over once the venue closes, even before its end time', async () => { + clock.set('2026-09-25T18:48:00Z'); + const late = await seedDeal(pool, tart, { startsAt: clock.now(), endsAt: later(HOUR) }); + clock.set('2026-09-25T19:05:00Z'); + for (const dealId of [late.id, deal.id]) { + const menuItemId = dealId === late.id ? tart.id : eclair.id; + await expect(service.create(GUEST, { menuItemId, dealId })).rejects.toMatchObject({ + status: 409, + code: 'deal_not_active', + }); + } + expect(await quantityLeft(late.id)).toBe(5); expect(await quantityLeft(deal.id)).toBe(2); }); diff --git a/backend/src/services/bookings.ts b/backend/src/services/bookings.ts index 8e793fb..381d1ac 100644 --- a/backend/src/services/bookings.ts +++ b/backend/src/services/bookings.ts @@ -8,6 +8,7 @@ import { normalizeBookingCode, qrPayload, } from '../domain/bookings.ts'; +import { dealOverAt } from '../domain/deals.ts'; import type { Booking, Deal, MenuItem, Venue } from '../domain/models.ts'; import type { BookingNotice, Notifier } from '../ports/notifier.ts'; import * as bookings from '../repositories/bookings.ts'; @@ -81,8 +82,8 @@ const bookingNotFound = () => notFound('booking_not_found', 'Booking not found') const bookingExpired = () => conflict('booking_expired', 'The booking has expired, the portion is released'); const bookingNotActive = () => conflict('booking_not_active', 'The booking is already redeemed or cancelled'); -function dealRunning(deal: Deal, now: Date): boolean { - return deal.cancelledAt === null && deal.startsAt <= now && deal.endsAt > now; +function dealRunning(deal: Deal, venue: Venue, now: Date): boolean { + return deal.cancelledAt === null && deal.startsAt <= now && dealOverAt(deal, venue) > now; } async function lockBookableItem( @@ -100,10 +101,16 @@ async function lockBookableItem( return { item, venue }; } -async function lockBookableDeal(db: Queryable, item: MenuItem, dealId: number, now: Date): Promise { +async function lockBookableDeal( + db: Queryable, + item: MenuItem, + venue: Venue, + dealId: number, + now: Date, +): Promise { const deal = await deals.lockInVenue(db, item.venueId, dealId); if (deal?.menuItemId !== item.id) throw notFound('deal_not_found', 'Deal not found'); - if (!dealRunning(deal, now)) { + if (!dealRunning(deal, venue, now)) { throw conflict('deal_not_active', 'The deal is cancelled, over or has not started yet'); } return deal; @@ -161,10 +168,11 @@ export function createBookingsService({ ): Promise { if (!(await users.lock(client, userId))) throw notFound('user_not_found', 'User not found'); const { item, venue } = await lockBookableItem(client, input.menuItemId, userId); + const deal = + input.dealId === undefined ? null : await lockBookableDeal(client, item, venue, input.dealId, now); if (!isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone)) { throw conflict('venue_closed', 'The venue is closed now'); } - const deal = input.dealId === undefined ? null : await lockBookableDeal(client, item, input.dealId, now); const activeItems = await bookings.listActiveItemIds(client, userId); if (activeItems.includes(item.id)) { throw conflict('booking_exists', 'You already have an active booking of this item'); diff --git a/backend/src/services/catalog.int.test.ts b/backend/src/services/catalog.int.test.ts index ba60f20..6ecb5ba 100644 --- a/backend/src/services/catalog.int.test.ts +++ b/backend/src/services/catalog.int.test.ts @@ -278,6 +278,66 @@ describe('deal card', () => { }); }); +describe('deals after the closing', () => { + const at = (iso: string) => new Date(iso); + + it('treats a deal as over once the venue closes, even before its end time', async () => { + const venue = await seedVenue(pool, 1, { name: 'Зерно', location: BAUMANA }); + const eclair = await seedMenuItem(pool, venue.id, { name: 'Эклер' }); + const deal = await seedDeal(pool, eclair, { + startsAt: at('2026-09-25T18:48:00Z'), + endsAt: at('2026-09-25T19:50:00Z'), + }); + clock.set('2026-09-25T18:55:00Z'); + expect((await catalog.deal(GUEST, deal.id)).deal.status).toBe('active'); + + clock.set('2026-09-25T19:05:00Z'); + await expect(catalog.deal(GUEST, deal.id)).rejects.toMatchObject({ status: 404, code: 'deal_not_found' }); + expect((await catalog.venue(GUEST, venue.id)).deals).toEqual([]); + expect((await catalog.venues(GUEST, near(BAUMANA))).map((card) => card.activeDeals)).toEqual([0]); + expect(await deals.listVisible(pool, [venue.id], clock.now())).toEqual([]); + }); + + it('hides a deal published while the venue was closed', async () => { + const venue = await seedVenue(pool, 1, { name: 'Зерно', location: BAUMANA }); + const eclair = await seedMenuItem(pool, venue.id, { name: 'Эклер' }); + const deal = await seedDeal(pool, eclair, { + startsAt: at('2026-09-25T19:30:00Z'), + endsAt: at('2026-09-25T20:30:00Z'), + }); + clock.set('2026-09-25T19:45:00Z'); + await expect(catalog.deal(GUEST, deal.id)).rejects.toMatchObject({ code: 'deal_not_found' }); + expect((await catalog.venues(GUEST, near(BAUMANA))).map((card) => card.activeDeals)).toEqual([0]); + }); + + it('keeps deals past midnight and round the clock until their end', async () => { + const bar = await seedVenue(pool, 1, { + name: 'Бар', + location: BAUMANA, + opensAt: '18:00', + closesAt: '02:00', + }); + const allDay = await seedVenue(pool, 2, { + name: 'Пенка', + location: BAUMANA, + opensAt: '00:00', + closesAt: '00:00', + }); + const late = await seedDeal(pool, await seedMenuItem(pool, bar.id), { + startsAt: at('2026-09-25T20:00:00Z'), + endsAt: at('2026-09-25T22:30:00Z'), + }); + const night = await seedDeal(pool, await seedMenuItem(pool, allDay.id), { + startsAt: at('2026-09-25T20:00:00Z'), + endsAt: at('2026-09-26T02:00:00Z'), + }); + clock.set('2026-09-25T22:15:00Z'); + expect((await catalog.deal(GUEST, late.id)).deal.status).toBe('active'); + clock.set('2026-09-26T01:00:00Z'); + expect((await catalog.deal(GUEST, night.id)).deal.status).toBe('active'); + }); +}); + describe('demo mode', () => { it('measures from the centre of Kazan when the point is more than 50 km away', async () => { await seedDemoVenue(pool, 900001, { name: 'Зерно', location: BAUMANA }); diff --git a/backend/src/services/deals.int.test.ts b/backend/src/services/deals.int.test.ts index 83f1be8..1356727 100644 --- a/backend/src/services/deals.int.test.ts +++ b/backend/src/services/deals.int.test.ts @@ -345,6 +345,28 @@ describe('deals and opening hours', () => { }); }); +describe('deals after the closing', () => { + it('counts a deal as ended once the venue closes, even before its end time', async () => { + clock.set('2026-09-25T18:48:00Z'); + const late = await seedDeal(pool, item, { + startsAt: clock.now(), + endsAt: new Date('2026-09-25T19:50:00Z'), + }); + expect((await service.list(OWNER, 'active')).map((view) => view.deal.id)).toEqual([late.id]); + + clock.set('2026-09-25T19:05:00Z'); + expect(await service.list(OWNER, 'active')).toEqual([]); + expect(await service.list(OWNER, 'finished')).toMatchObject([{ status: 'ended', deal: { id: late.id } }]); + await expect(service.update(OWNER, late.id, { quantityLeft: 1 })).rejects.toMatchObject({ + status: 409, + code: 'deal_finished', + }); + + clock.set('2026-09-26T06:00:00Z'); + expect((await service.create(OWNER, input({ endsAt: inHours(1) }))).status).toBe('active'); + }); +}); + describe('listing deals', () => { it('lists live deals by end time and finished deals of the last 7 days newest first', async () => { const other = await seedMenuItem(pool, venue.id, { name: 'Круассан', priceRub: 150 }); diff --git a/backend/src/services/deals.test.ts b/backend/src/services/deals.test.ts index ed1a5c6..5e8c1e4 100644 --- a/backend/src/services/deals.test.ts +++ b/backend/src/services/deals.test.ts @@ -16,27 +16,36 @@ const deal: Deal = { }; const at = (iso: string) => new Date(iso); +const ZERNO = { opensAt: '08:00', closesAt: '22:00', timezone: 'Europe/Moscow' }; describe('dealStatus', () => { it('is scheduled until the start and active from the start', () => { - expect(dealStatus(deal, at('2026-09-25T09:59:59.999Z'))).toBe('scheduled'); - expect(dealStatus(deal, at('2026-09-25T10:00:00Z'))).toBe('active'); - expect(dealStatus(deal, at('2026-09-25T11:59:59.999Z'))).toBe('active'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T09:59:59.999Z'))).toBe('scheduled'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T10:00:00Z'))).toBe('active'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T11:59:59.999Z'))).toBe('active'); }); it('ends exactly at the end time', () => { - expect(dealStatus(deal, at('2026-09-25T12:00:00Z'))).toBe('ended'); + expect(dealStatus(deal, ZERNO, at('2026-09-25T12:00:00Z'))).toBe('ended'); + }); + + it('ends at the closing of the venue when the end time is later', () => { + const late = { ...deal, startsAt: at('2026-09-25T18:48:00Z'), endsAt: at('2026-09-25T19:50:00Z') }; + expect(dealStatus(late, ZERNO, at('2026-09-25T18:59:59.999Z'))).toBe('active'); + expect(dealStatus(late, ZERNO, at('2026-09-25T19:00:00Z'))).toBe('ended'); + const allDay = { ...ZERNO, opensAt: '00:00', closesAt: '00:00' }; + expect(dealStatus(late, allDay, at('2026-09-25T19:30:00Z'))).toBe('active'); }); it('is sold out when no portions are left, even after the end', () => { const soldOut = { ...deal, quantityLeft: 0 }; - expect(dealStatus(soldOut, at('2026-09-25T11:00:00Z'))).toBe('sold_out'); - expect(dealStatus(soldOut, at('2026-09-25T13:00:00Z'))).toBe('sold_out'); + expect(dealStatus(soldOut, ZERNO, at('2026-09-25T11:00:00Z'))).toBe('sold_out'); + expect(dealStatus(soldOut, ZERNO, at('2026-09-25T13:00:00Z'))).toBe('sold_out'); }); it('reports cancellation before anything else', () => { const cancelled = { ...deal, quantityLeft: 0, cancelledAt: at('2026-09-25T10:30:00Z') }; - expect(dealStatus(cancelled, at('2026-09-25T09:00:00Z'))).toBe('cancelled'); - expect(dealStatus(cancelled, at('2026-09-25T13:00:00Z'))).toBe('cancelled'); + expect(dealStatus(cancelled, ZERNO, at('2026-09-25T09:00:00Z'))).toBe('cancelled'); + expect(dealStatus(cancelled, ZERNO, at('2026-09-25T13:00:00Z'))).toBe('cancelled'); }); }); diff --git a/backend/src/services/deals.ts b/backend/src/services/deals.ts index 1a30c40..258d52d 100644 --- a/backend/src/services/deals.ts +++ b/backend/src/services/deals.ts @@ -1,7 +1,9 @@ import { withTransaction, type Pool, type Queryable } from '../db/pool.ts'; +import { dealOverAt } from '../domain/deals.ts'; import type { Deal, MenuItem, Venue } from '../domain/models.ts'; import * as deals from '../repositories/deals.ts'; import * as menuItems from '../repositories/menu-items.ts'; +import * as venues from '../repositories/venues.ts'; import type { Clock } from '../shared/clock.ts'; import { conflict, notFound, unprocessable } from '../shared/errors.ts'; import { isOpenAt, nextClosingAt } from '../shared/time.ts'; @@ -48,21 +50,32 @@ const HOUR_MS = 3_600_000; const MAX_DEAL_WINDOW_MS = 24 * HOUR_MS; const FINISHED_HISTORY_MS = 7 * 24 * HOUR_MS; -export function dealStatus(deal: Deal, now: Date): DealStatus { +export function dealStatus( + deal: Deal, + venue: Pick, + now: Date, +): DealStatus { if (deal.cancelledAt) return 'cancelled'; if (deal.quantityLeft === 0) return 'sold_out'; - if (deal.endsAt <= now) return 'ended'; + if (dealOverAt(deal, venue) <= now) return 'ended'; if (deal.startsAt > now) return 'scheduled'; return 'active'; } export async function dealViews(db: Queryable, list: readonly Deal[], now: Date): Promise { const itemIds = [...new Set(list.map((deal) => deal.menuItemId))]; - const items = new Map((await menuItems.findByIds(db, itemIds)).map((item) => [item.id, item])); + const venueIds = [...new Set(list.map((deal) => deal.venueId))]; + const [foundItems, foundVenues] = await Promise.all([ + menuItems.findByIds(db, itemIds), + venues.findByIds(db, venueIds), + ]); + const items = new Map(foundItems.map((item) => [item.id, item])); + const places = new Map(foundVenues.map((venue) => [venue.id, venue])); return list.map((deal) => { const item = items.get(deal.menuItemId); - if (!item) throw new Error(`Deal ${deal.id} refers to a missing menu item`); - return { deal, item, status: dealStatus(deal, now) }; + const venue = places.get(deal.venueId); + if (!item || !venue) throw new Error(`Deal ${deal.id} refers to a missing menu item or venue`); + return { deal, item, status: dealStatus(deal, venue, now) }; }); } @@ -139,7 +152,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer }, now, ); - return { deal, item, status: dealStatus(deal, now) }; + return { deal, item, status: dealStatus(deal, venue, now) }; }); }, @@ -152,7 +165,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer const deal = await deals.lockInVenue(client, venue.id, dealId); if (!deal) throw dealNotFound(); const now = clock.now(); - if (deal.cancelledAt || deal.endsAt <= now || item.archivedAt) { + if (deal.cancelledAt || dealOverAt(deal, venue) <= now || item.archivedAt) { throw conflict('deal_finished', 'The deal is cancelled or over, create a new one'); } const quantityLeft = patch.quantityLeft ?? deal.quantityLeft; @@ -175,7 +188,7 @@ export function createDealsService({ pool, clock }: DealsDependencies): DealsSer quantityLeft, endsAt: patch.endsAt ?? deal.endsAt, }); - return { deal: updated, item, status: dealStatus(updated, now) }; + return { deal: updated, item, status: dealStatus(updated, venue, now) }; }); }, diff --git a/backend/src/services/recommendations.ts b/backend/src/services/recommendations.ts index 25fe818..f2d62f1 100644 --- a/backend/src/services/recommendations.ts +++ b/backend/src/services/recommendations.ts @@ -106,7 +106,7 @@ function toRecommendedOffer( offerId: offer.id, item, venue, - deal: deal ? { deal, item, status: dealStatus(deal, now) } : null, + deal: deal ? { deal, item, status: dealStatus(deal, venue, now) } : null, score: recommendation.score, distanceM: recommendation.distanceM, priceRub: recommendation.priceRub, diff --git a/backend/test/venue-bot.ts b/backend/test/venue-bot.ts index 537b7f6..b9c4ed4 100644 --- a/backend/test/venue-bot.ts +++ b/backend/test/venue-bot.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import { normalizeBookingCode } from '../src/domain/bookings.ts'; +import { dealOverAt } from '../src/domain/deals.ts'; import { menuNameKey } from '../src/domain/menu-imports.ts'; import type { Booking, Deal, MenuImport, MenuItem, ParsedMenuItem, Venue } from '../src/domain/models.ts'; import type { VenueAnalytics } from '../src/services/analytics.ts'; @@ -97,11 +98,17 @@ export function venueFake(clock: Clock) { const dealView = (deal: Deal): DealView => { const item = state.items.find((candidate) => candidate.id === deal.menuItemId); if (!item) throw new Error(`Deal ${deal.id} has no menu item`); - return { deal: { ...deal }, item: { ...item }, status: dealStatus(deal, clock.now()) }; + return { + deal: { ...deal }, + item: { ...item }, + status: dealStatus(deal, state.venue ?? sampleVenue, clock.now()), + }; }; const isLive = (deal: Deal) => - deal.cancelledAt === null && deal.quantityLeft > 0 && deal.endsAt > clock.now(); + deal.cancelledAt === null && + deal.quantityLeft > 0 && + dealOverAt(deal, state.venue ?? sampleVenue) > clock.now(); const bookingView = (booking: Booking): BookingView => { const item = state.items.find((candidate) => candidate.id === booking.menuItemId); diff --git a/docs/scenario.md b/docs/scenario.md index a3bd6d9..477a138 100644 --- a/docs/scenario.md +++ b/docs/scenario.md @@ -128,7 +128,7 @@ VENUE="Authorization: Bearer local-demo-venue-token-not-secret" | E6 | Отправить несуществующий код `AAAAAA` | «Бронь с таким кодом не найдена в вашем заведении. Проверьте код и введите его ещё раз.»; бот ждёт следующий ввод. В API `404` `booking_not_found`, в мини-приложении «Бронь с таким кодом не найдена в вашем заведении», при сканировании чужого QR «Это не QR брони ППшкин» | | E7 | Дождаться окончания брони (60 минут, конец акции или закрытие заведения) | Задача `expire_bookings` в течение минуты переводит бронь в `expired`, порция возвращается в горящую позицию. Гостю приходит «Бронь <код> истекла в ЧЧ:ММ: <позиция>, <заведение>. Код больше не действует.» с кнопкой «Подобрать другое»; в мини-приложении «Время брони вышло» и «Найти другое» | | E8 | Погасить истёкшую бронь | Бот: «Срок брони истёк. Гость может оформить новую»; API `409` `booking_expired`; уже погашенная или отменённая бронь: «Бронь уже погашена или отменена», `409` `booking_not_active` | -| E9 | Забронировать в закрытом заведении | Бот: «Заведение сейчас закрыто, бронь не получится. Посмотрим другое: /eat»; API `409` `venue_closed`; мини-приложение «Заведение сейчас закрыто, бронь недоступна» | +| E9 | Забронировать в закрытом заведении | Бот: «Заведение сейчас закрыто, бронь не получится. Посмотрим другое: /eat»; API `409` `venue_closed`; мини-приложение «Заведение сейчас закрыто, бронь недоступна». Горящее предложение после закрытия заведения считается закончившимся, даже если его время ещё не вышло: ссылка на него отвечает «Это предложение уже закончилось.», в подборе и в кабинете его нет, старая кнопка «Забронировать» отвечает «Предложение уже закончилось. Посмотрим другое: /eat», API `409` `deal_not_active` | | E10 | ChadGPT не ответил или ключа нет | «Сервис распознавания не ответил. Попробуйте через минуту или напишите вручную, например: Сырники 350» или «Распознавание фото сейчас выключено. Напишите, что съели, например: Сырники 350» с кнопкой «Ввести вручную». Повтор без перезапуска: отправить фото ещё раз через минуту или записать вручную | | E11 | Больше 20 распознаваний за час | «Слишком много запросов подряд. Попробуйте через час или запишите вручную, например: Сырники 350»; ручная запись работает | | E12 | Нажать кнопку старого сообщения или ту же кнопку повторно | Всплывает «Кнопка устарела» или «Эта кнопка уже нажата», действие не повторяется. Повторная доставка того же обновления MAX отбрасывается по ключу в `processed_updates`, запись не дублируется | diff --git a/openapi.yaml b/openapi.yaml index 8871d58..7690e7e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -962,7 +962,8 @@ components: - ended - cancelled description: active - продаётся; scheduled - ещё не началась; sold_out - порции - закончились; ended - время вышло; cancelled - снята заведением + закончились; ended - время вышло или заведение закрылось после + начала предложения; cancelled - снята заведением required: - id - menuItemId @@ -5295,14 +5296,15 @@ paths: предложения нет или оно на другую позицию. offer_not_found (404): предложение показано другому гостю или на другую позицию, бронируйте без offerId. venue_closed (409): заведение сейчас закрыто, покажите часы - работы. deal_not_active (409): горящее предложение снято, закончилось - или ещё не началось, предложите бронь по цене меню без dealId. - booking_exists (409): у гостя уже есть активная бронь этой позиции, - откройте её из GET /api/v1/bookings. deal_sold_out (409): порции по - предложению закончились, предложите бронь по цене меню без dealId. - too_many_bookings (409): уже 3 активные брони, отмените одну из них или - дождитесь, пока заведение её погасит. menu_item_unavailable (422): - заведение скрыло позицию от гостей." + работы. deal_not_active (409): горящее предложение снято, закончилось (в + том числе потому что заведение закрылось) или ещё не началось, + предложите бронь по цене меню без dealId. booking_exists (409): у гостя + уже есть активная бронь этой позиции, откройте её из GET + /api/v1/bookings. deal_sold_out (409): порции по предложению + закончились, предложите бронь по цене меню без dealId. too_many_bookings + (409): уже 3 активные брони, отмените одну из них или дождитесь, пока + заведение её погасит. menu_item_unavailable (422): заведение скрыло + позицию от гостей." requestBody: required: true content: