From 34245e4b061025349313163176204a203a4a4f53 Mon Sep 17 00:00:00 2001 From: jckail Date: Thu, 9 Jul 2026 16:11:10 -0700 Subject: [PATCH] Add award watchlist alerts: watch pages, notify when value improves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap item ("Award watchlist alerts — persist scraped routes; notify when space/value improves"). Composes the existing PageScraper, deal extraction (IngestDealPage/realizedCpp), and Notifier/Mailer ports into a vertical slice. - domain: AwardWatch entity + factory (label/threshold/url invariants) with pure shouldNotify (fires at/above threshold, then only on improvement — a standing deal doesn't ping daily) and recordCheck bookkeeping; AwardWatchRepository port; INVALID_AWARD_WATCH / AWARD_WATCH_NOT_FOUND. - db: award_watch table (¢/pt as integer milli-cents) + migration 0006 (additive only) + Drizzle repo. - application: Create/List/Delete (owner-scoped 404 contract) + CheckAwardWatches — re-scrapes each watch via IngestDealPage so extraction heuristics stay in one place; scrape failures counted, never abort. - web: GET/POST /api/v1/watches, DELETE /api/v1/watches/{id}; container wiring. - worker: new `watch` job — chat fan-out via Notifier, per-owner email via UserDirectory/Mailer; Firecrawl or stub scraper (worker env grows FIRECRAWL_*). compose comment. - infra: daily WatchTask (11:00 UTC) reusing the worker image, secrets, chat webhooks, and the opt-in Firecrawl secret. - contracts + OpenAPI + api-client methods + docs (api.md, bot.md). Verification: 134 tests pass (103 core incl. 6 new + 20 bot + 11 extension); typecheck + lint clean; worker bundle builds; cdk synth clean with WatchTask. Co-Authored-By: Claude Fable 5 --- apps/web/src/app/api/v1/watches/[id]/route.ts | 15 + apps/web/src/app/api/v1/watches/route.ts | 31 + apps/web/src/server/container.ts | 11 + apps/worker/src/container.ts | 20 + apps/worker/src/env.ts | 4 + apps/worker/src/index.ts | 10 +- apps/worker/src/jobs/check-watches.ts | 80 +++ docker-compose.yml | 1 + docs/api.md | 12 + docs/bot.md | 4 + infra/lib/app-stack.ts | 44 +- packages/api-client/src/index.ts | 17 + .../core/drizzle/0006_productive_thor.sql | 14 + packages/core/drizzle/meta/0006_snapshot.json | 638 ++++++++++++++++++ packages/core/drizzle/meta/_journal.json | 7 + .../src/application/loyalty/award-watches.ts | 131 ++++ packages/core/src/contracts/index.ts | 48 ++ packages/core/src/contracts/openapi.ts | 32 + packages/core/src/domain/errors.ts | 16 + .../core/src/domain/loyalty/award-watch.ts | 127 ++++ packages/core/src/index.ts | 3 + packages/core/src/infrastructure/db/schema.ts | 21 + .../drizzle-award-watch-repository.ts | 92 +++ packages/core/test/award-watches.test.ts | 146 ++++ packages/core/test/fakes.ts | 32 + 25 files changed, 1554 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/v1/watches/[id]/route.ts create mode 100644 apps/web/src/app/api/v1/watches/route.ts create mode 100644 apps/worker/src/jobs/check-watches.ts create mode 100644 packages/core/drizzle/0006_productive_thor.sql create mode 100644 packages/core/drizzle/meta/0006_snapshot.json create mode 100644 packages/core/src/application/loyalty/award-watches.ts create mode 100644 packages/core/src/domain/loyalty/award-watch.ts create mode 100644 packages/core/src/infrastructure/repositories/drizzle-award-watch-repository.ts create mode 100644 packages/core/test/award-watches.test.ts diff --git a/apps/web/src/app/api/v1/watches/[id]/route.ts b/apps/web/src/app/api/v1/watches/[id]/route.ts new file mode 100644 index 0000000..eea7f39 --- /dev/null +++ b/apps/web/src/app/api/v1/watches/[id]/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +import { getContainer } from "@/server/container"; +import { withAuthenticatedUser } from "@/server/http"; + +type Context = { params: Promise<{ id: string }> }; + +/** Stop watching a page. */ +export function DELETE(_request: Request, context: Context) { + return withAuthenticatedUser(async (userId) => { + const { id } = await context.params; + await getContainer().useCases.deleteAwardWatch.execute(userId, id); + return new NextResponse(null, { status: 204 }); + }); +} diff --git a/apps/web/src/app/api/v1/watches/route.ts b/apps/web/src/app/api/v1/watches/route.ts new file mode 100644 index 0000000..2b1a180 --- /dev/null +++ b/apps/web/src/app/api/v1/watches/route.ts @@ -0,0 +1,31 @@ +import { + createAwardWatchRequestSchema, + toAwardWatchDto, +} from "@pointup/core/contracts"; +import { NextResponse } from "next/server"; + +import { getContainer } from "@/server/container"; +import { withAuthenticatedUser } from "@/server/http"; + +/** List the caller's award watches. */ +export function GET() { + return withAuthenticatedUser(async (userId) => { + const watches = + await getContainer().useCases.listAwardWatches.execute(userId); + return NextResponse.json(watches.map(toAwardWatchDto)); + }); +} + +/** Watch an award/deal page; the worker re-scrapes and notifies on improvement. */ +export function POST(request: Request) { + return withAuthenticatedUser(async (userId) => { + const body = createAwardWatchRequestSchema.parse(await request.json()); + const watch = await getContainer().useCases.createAwardWatch.execute({ + userId, + url: body.url, + label: body.label, + minCentsPerPoint: body.minCentsPerPoint, + }); + return NextResponse.json(toAwardWatchDto(watch), { status: 201 }); + }); +} diff --git a/apps/web/src/server/container.ts b/apps/web/src/server/container.ts index a7bf501..50a381e 100644 --- a/apps/web/src/server/container.ts +++ b/apps/web/src/server/container.ts @@ -13,6 +13,10 @@ import { DrizzleTripGoalRepository, BedrockAssistant, BulkUpdateMembershipNumbers, + CreateAwardWatch, + DeleteAwardWatch, + DrizzleAwardWatchRepository, + ListAwardWatches, DeleteCustomValuation, DrizzleCustomValuationRepository, ListCustomValuations, @@ -92,6 +96,9 @@ export interface Container { chatWithAssistant: ChatWithAssistant; getValueAdvice: GetValueAdvice; listCustomValuations: ListCustomValuations; + createAwardWatch: CreateAwardWatch; + listAwardWatches: ListAwardWatches; + deleteAwardWatch: DeleteAwardWatch; setCustomValuation: SetCustomValuation; deleteCustomValuation: DeleteCustomValuation; ingestDealPage: IngestDealPage; @@ -149,6 +156,7 @@ function buildContainer(): Container { const tripGoals = new DrizzleTripGoalRepository(db); const shares = new DrizzlePortfolioShareRepository(db); const customValuations = new DrizzleCustomValuationRepository(db); + const awardWatches = new DrizzleAwardWatchRepository(db); const vault = buildVault(); const gateway = buildTravelProviderGateway({ aggregator: @@ -256,6 +264,9 @@ function buildContainer(): Container { ), getValueAdvice: new GetValueAdvice(listLoyaltyAccounts), listCustomValuations: new ListCustomValuations(customValuations), + createAwardWatch: new CreateAwardWatch(awardWatches), + listAwardWatches: new ListAwardWatches(awardWatches), + deleteAwardWatch: new DeleteAwardWatch(awardWatches), setCustomValuation: new SetCustomValuation(customValuations), deleteCustomValuation: new DeleteCustomValuation(customValuations), ingestDealPage: new IngestDealPage(scraper), diff --git a/apps/worker/src/container.ts b/apps/worker/src/container.ts index b1c0839..4b12c2d 100644 --- a/apps/worker/src/container.ts +++ b/apps/worker/src/container.ts @@ -1,5 +1,10 @@ import { BuildPortfolioDigest, + CheckAwardWatches, + DrizzleAwardWatchRepository, + FirecrawlPageScraper, + IngestDealPage, + StubPageScraper, buildTravelProviderGateway, createDb, DrizzleBalanceSnapshotRepository, @@ -13,6 +18,7 @@ import { SyncLoyaltyAccount, type CredentialVault, type LoyaltyAccountRepository, + type PageScraper, } from "@pointup/core"; import type { WorkerEnv } from "./env"; @@ -22,6 +28,7 @@ export interface WorkerContainer { useCases: { syncAllLoyaltyAccounts: SyncAllLoyaltyAccounts; buildPortfolioDigest: BuildPortfolioDigest; + checkAwardWatches: CheckAwardWatches; }; } @@ -31,6 +38,7 @@ export function createContainer(env: WorkerEnv): WorkerContainer { const accounts = new DrizzleLoyaltyAccountRepository(db); const balances = new DrizzleBalanceSnapshotRepository(db); const tripGoals = new DrizzleTripGoalRepository(db); + const awardWatches = new DrizzleAwardWatchRepository(db); const vault: CredentialVault = env.OP_CONNECT_HOST && env.OP_CONNECT_TOKEN @@ -56,6 +64,14 @@ export function createContainer(env: WorkerEnv): WorkerContainer { const listAccounts = new ListLoyaltyAccounts(accounts, balances); + const scraper: PageScraper = + env.FIRECRAWL_API_KEY + ? new FirecrawlPageScraper({ + apiKey: env.FIRECRAWL_API_KEY, + baseUrl: env.FIRECRAWL_BASE_URL, + }) + : new StubPageScraper(); + return { accounts, useCases: { @@ -64,6 +80,10 @@ export function createContainer(env: WorkerEnv): WorkerContainer { listAccounts, new ListTripGoals(tripGoals, balances), ), + checkAwardWatches: new CheckAwardWatches( + awardWatches, + new IngestDealPage(scraper), + ), }, }; } diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 72d7bb0..1dc905e 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -51,6 +51,10 @@ const envSchema = z.object({ AGGREGATOR_API_URL: z.url().optional(), AGGREGATOR_API_KEY: z.string().min(1).optional(), + /** Optional Firecrawl for the award-watch scrape job (falls back to stub). */ + FIRECRAWL_API_KEY: z.string().min(1).optional(), + FIRECRAWL_BASE_URL: z.url().optional(), + /** Optional chat digest delivery — Slack / Discord incoming webhooks. */ SLACK_WEBHOOK_URL: z.url().optional(), DISCORD_WEBHOOK_URL: z.url().optional(), diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 39f5fe3..1285b94 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,5 +1,6 @@ import { createContainer } from "./container"; import { loadEnv } from "./env"; +import { checkWatches } from "./jobs/check-watches"; import { runMigrations } from "./jobs/migrate"; import { sendAlerts } from "./jobs/send-alerts"; import { sendDigests } from "./jobs/send-digests"; @@ -8,7 +9,7 @@ import { createMailer } from "./mailers"; import { createNotifier } from "./notifiers"; import { createUserDirectory } from "./user-directory"; -const JOBS = ["sync", "digest", "alerts", "migrate"] as const; +const JOBS = ["sync", "digest", "alerts", "watch", "migrate"] as const; type Job = (typeof JOBS)[number]; async function main(): Promise { @@ -29,6 +30,13 @@ async function main(): Promise { const container = createContainer(env); if (job === "sync") { await syncAllUsers(container); + } else if (job === "watch") { + await checkWatches( + container, + createUserDirectory(env), + createMailer(env), + createNotifier(env), + ); } else if (job === "alerts") { await sendAlerts( container, diff --git a/apps/worker/src/jobs/check-watches.ts b/apps/worker/src/jobs/check-watches.ts new file mode 100644 index 0000000..fb1c9ab --- /dev/null +++ b/apps/worker/src/jobs/check-watches.ts @@ -0,0 +1,80 @@ +import type { + AwardWatchHit, + Mailer, + Notifier, + OutboundEmail, + OutboundNotification, + UserDirectory, +} from "@pointup/core"; + +import type { WorkerContainer } from "../container"; + +function hitLine(hit: AwardWatchHit): string { + return `“${hit.watch.label}”: ${hit.bestRealizedCpp}¢/pt — ${hit.bestDealTitle} (${hit.watch.url})`; +} + +export function renderWatchHitsChat( + hits: readonly AwardWatchHit[], +): OutboundNotification { + const markdown = [ + `*PointBot award watch* — ${hits.length} page${hits.length === 1 ? "" : "s"} improved:`, + ...hits.map((h) => `• ${hitLine(h)}`), + ].join("\n"); + return { text: markdown.replace(/\*/g, ""), markdown }; +} + +export function renderWatchHitsEmail( + hits: readonly AwardWatchHit[], + to: string, +): OutboundEmail { + const subject = `PointBot: award value improved on ${hits.length} watched page${hits.length === 1 ? "" : "s"}`; + const text = [ + "Value improved on pages you're watching:", + "", + ...hits.map((h) => `- ${hitLine(h)}`), + ].join("\n"); + return { to, subject, text }; +} + +/** + * Scheduled job: re-scrape every award watch and notify owners whose watches + * hit their threshold with an improved value. Chat delivery is global + * (workspace webhooks); email goes to each watch owner. + */ +export async function checkWatches( + container: WorkerContainer, + directory: UserDirectory, + mailer: Mailer, + notifier: Notifier | null, +): Promise { + const result = await container.useCases.checkAwardWatches.execute(); + console.info( + `[watch] checked ${result.checked}, failed ${result.failed}, hits ${result.hits.length}`, + ); + if (result.hits.length === 0) return; + + if (notifier) { + await notifier + .notify(renderWatchHitsChat(result.hits)) + .catch((error: unknown) => + console.warn("[watch] chat notify failed", error), + ); + } + + const byUser = new Map(); + for (const hit of result.hits) { + const list = byUser.get(hit.watch.userId) ?? []; + list.push(hit); + byUser.set(hit.watch.userId, list); + } + + for (const [userId, hits] of byUser) { + const email = await directory.getEmail(userId).catch(() => null); + if (!email) continue; + await mailer + .send(renderWatchHitsEmail(hits, email)) + .catch((error: unknown) => + console.warn(`[watch] user=${userId} email failed`, error), + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 80dcbef..760a885 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: # docker compose run --rm worker sync # docker compose run --rm worker digest # docker compose run --rm worker alerts + # docker compose run --rm worker watch worker: build: context: . diff --git a/docs/api.md b/docs/api.md index 7c11d66..72b6dcb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,6 +22,8 @@ Every surface — web app, mobile, browser extension — talks to the same versi | `PROVIDER_NOT_SUPPORTED` | 422 | Provider id is not in the catalog | | `INVALID_MEMBERSHIP_NUMBER` | 422 | Membership number is blank | | `INVALID_VALUATION` | 422 | Custom cents-per-point is ≤ 0 or > 100 | +| `INVALID_AWARD_WATCH` | 422 | Watch label/threshold failed validation | +| `AWARD_WATCH_NOT_FOUND` | 404 | Watch does not exist **or is not yours** | | `INVALID_BALANCE` | 422 | Points value is negative or fractional | | `INVALID_CAPTURE_TIME` | 422 | Capture timestamp is malformed or in the future | | `INVALID_GOAL_TITLE` | 422 | Goal title/notes failed validation | @@ -295,6 +297,16 @@ Set (or replace) a provider's cents-per-point override (`0 < v ≤ 100`). Return Clear the override, reverting the provider to its editorial valuation. Returns `204`. +### `GET /api/v1/watches` / `POST /api/v1/watches` / `DELETE /api/v1/watches/{id}` + +Award watchlist: watch an award-chart or deal page and get notified (chat + email via the worker's daily `watch` job) when a redemption at or above your cents-per-point threshold appears — and again only when the best seen value improves. + +```json +{ "url": "https://blog.example/hyatt-sweet-spots", "label": "Hyatt sweet spots", "minCentsPerPoint": 2 } +``` + +Returns `201` with the watch (including `bestSeenCentsPerPoint`, `lastCheckedAt`, `lastNotifiedAt`). + ### `GET /api/v1/loyalty-accounts/{id}/balances` Balance history, newest first. Query parameter `limit` (1–365, default 50). diff --git a/docs/bot.md b/docs/bot.md index 5148a82..5841625 100644 --- a/docs/bot.md +++ b/docs/bot.md @@ -114,6 +114,10 @@ npm run dev --workspace @pointup/worker # then: alerts Thresholds are tunable via `ALERT_EXPIRY_WARNING_DAYS` and `ALERT_BIG_CHANGE_PERCENT`. In AWS the `AlertsTask` runs daily at 12:00 UTC. +Related: the daily `watch` job re-scrapes **award watchlist** pages +(`/api/v1/watches`) and notifies when a watched page's best realized ¢/pt +improves past your threshold (`WatchTask`, 11:00 UTC). + ## Deploying the bot `Dockerfile.bot` builds a self-contained bundle (`node index.cjs`, port 8080, diff --git a/infra/lib/app-stack.ts b/infra/lib/app-stack.ts index 0da29f0..5e5481c 100644 --- a/infra/lib/app-stack.ts +++ b/infra/lib/app-stack.ts @@ -509,7 +509,49 @@ export class AppStack extends cdk.Stack { }), ); - for (const task of [syncTask, digestTask, alertsTask]) { + // ─── Award watch task (daily) ────────────────────────────────────────── + // Re-scrapes watched award/deal pages and notifies owners when value + // improves past their threshold. Uses Firecrawl when configured, else the + // stub scraper. + const watchTask = new ecsPatterns.ScheduledFargateTask(this, "WatchTask", { + cluster, + // Daily at 11:00 UTC, before the alerts task. + schedule: events.Schedule.cron({ hour: "11", minute: "0" }), + subnetSelection: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + scheduledFargateTaskImageOptions: { + image: ecs.ContainerImage.fromDockerImageAsset(workerImage), + command: ["watch"], + cpu: 256, + memoryLimitMiB: 512, + environment: { + NODE_ENV: "production", + MAILER: digestFromEmail ? "ses" : "console", + ...(digestFromEmail ? { DIGEST_FROM_EMAIL: digestFromEmail } : {}), + ...(ctx("firecrawlBaseUrl") + ? { FIRECRAWL_BASE_URL: ctx("firecrawlBaseUrl")! } + : {}), + ...chatWebhookEnv, + }, + secrets: { + ...workerSecrets, + ...(firecrawlSecret + ? { FIRECRAWL_API_KEY: ecs.Secret.fromSecretsManager(firecrawlSecret) } + : {}), + }, + logDriver: ecs.LogDrivers.awsLogs({ + streamPrefix: "worker-watch", + logRetention: logs.RetentionDays.ONE_MONTH, + }), + }, + }); + watchTask.taskDefinition.taskRole.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ["ses:SendEmail", "ses:SendRawEmail"], + resources: ["*"], + }), + ); + + for (const task of [syncTask, digestTask, alertsTask, watchTask]) { database.connections.allowDefaultPortFrom( task.task.securityGroups![0]!, "Worker tasks to PostgreSQL", diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 896abee..9191182 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,7 +1,9 @@ import type { ActivityEventDto, + AwardWatchDto, BulkUpdateMembershipRequest, BulkUpdateMembershipResultDto, + CreateAwardWatchRequest, CustomValuationDto, SetCustomValuationRequest, ApiError, @@ -124,6 +126,21 @@ export class PointUpClient { return this.request("GET", "/api/v1/valuations"); } + listAwardWatches(): Promise { + return this.request("GET", "/api/v1/watches"); + } + + createAwardWatch(body: CreateAwardWatchRequest): Promise { + return this.request("POST", "/api/v1/watches", body); + } + + deleteAwardWatch(watchId: string): Promise { + return this.request( + "DELETE", + `/api/v1/watches/${encodeURIComponent(watchId)}`, + ); + } + setCustomValuation( providerId: string, body: SetCustomValuationRequest, diff --git a/packages/core/drizzle/0006_productive_thor.sql b/packages/core/drizzle/0006_productive_thor.sql new file mode 100644 index 0000000..3f4f4ec --- /dev/null +++ b/packages/core/drizzle/0006_productive_thor.sql @@ -0,0 +1,14 @@ +CREATE TABLE "award_watch" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "url" varchar(2048) NOT NULL, + "label" varchar(120) NOT NULL, + "min_cents_per_point_milli" integer NOT NULL, + "best_seen_cents_per_point_milli" integer, + "last_checked_at" timestamp with time zone, + "last_notified_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE INDEX "award_watch_user_id_idx" ON "award_watch" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/core/drizzle/meta/0006_snapshot.json b/packages/core/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..9cc6339 --- /dev/null +++ b/packages/core/drizzle/meta/0006_snapshot.json @@ -0,0 +1,638 @@ +{ + "id": "b8e8f160-5f4e-49c3-a8f6-8810bdd439ba", + "prevId": "9800d5e2-6e4f-447f-972f-81f81dfc0ca5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_event": { + "name": "activity_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "activity_event_user_occurred_idx": { + "name": "activity_event_user_occurred_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.award_watch": { + "name": "award_watch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "min_cents_per_point_milli": { + "name": "min_cents_per_point_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "best_seen_cents_per_point_milli": { + "name": "best_seen_cents_per_point_milli", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "award_watch_user_id_idx": { + "name": "award_watch_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.balance_snapshot": { + "name": "balance_snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "loyalty_account_id": { + "name": "loyalty_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "balance_snapshot_account_captured_idx": { + "name": "balance_snapshot_account_captured_idx", + "columns": [ + { + "expression": "loyalty_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "balance_snapshot_loyalty_account_id_loyalty_account_id_fk": { + "name": "balance_snapshot_loyalty_account_id_loyalty_account_id_fk", + "tableFrom": "balance_snapshot", + "tableTo": "loyalty_account", + "columnsFrom": [ + "loyalty_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loyalty_account": { + "name": "loyalty_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "membership_number": { + "name": "membership_number", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_ref": { + "name": "credential_ref", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "loyalty_account_user_id_idx": { + "name": "loyalty_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loyalty_account_expires_at_idx": { + "name": "loyalty_account_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loyalty_account_deleted_at_idx": { + "name": "loyalty_account_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loyalty_account_user_provider_unique": { + "name": "loyalty_account_user_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_share": { + "name": "portfolio_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "portfolio_share_user_id_idx": { + "name": "portfolio_share_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolio_share_token_unique": { + "name": "portfolio_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trip_goal": { + "name": "trip_goal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "target_points": { + "name": "target_points", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "target_date": { + "name": "target_date", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "account_ids": { + "name": "account_ids", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trip_goal_user_id_idx": { + "name": "trip_goal_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_valuation": { + "name": "user_provider_valuation", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "cents_per_point_milli": { + "name": "cents_per_point_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_provider_valuation_user_id_provider_id_pk": { + "name": "user_provider_valuation_user_id_provider_id_pk", + "columns": [ + "user_id", + "provider_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/drizzle/meta/_journal.json b/packages/core/drizzle/meta/_journal.json index b56e329..2225553 100644 --- a/packages/core/drizzle/meta/_journal.json +++ b/packages/core/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1783581823009, "tag": "0005_nice_rage", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1783638398794, + "tag": "0006_productive_thor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/application/loyalty/award-watches.ts b/packages/core/src/application/loyalty/award-watches.ts new file mode 100644 index 0000000..7ec1920 --- /dev/null +++ b/packages/core/src/application/loyalty/award-watches.ts @@ -0,0 +1,131 @@ +import { AwardWatchNotFoundError } from "../../domain/errors"; +import { + createAwardWatch, + recordCheck, + shouldNotify, + type AwardWatch, + type AwardWatchRepository, +} from "../../domain/loyalty/award-watch"; +import { realizedCpp } from "../../domain/loyalty/deals"; +import type { Clock } from "../ports"; +import { systemClock } from "../ports"; +import type { IngestDealPage } from "./ingest-deal-page"; + +export class CreateAwardWatch { + constructor( + private readonly watches: AwardWatchRepository, + private readonly clock: Clock = systemClock, + ) {} + + async execute(input: { + readonly userId: string; + readonly url: string; + readonly label: string; + readonly minCentsPerPoint: number; + }): Promise { + const watch = createAwardWatch({ ...input, now: this.clock.now() }); + await this.watches.insert(watch); + return watch; + } +} + +export class ListAwardWatches { + constructor(private readonly watches: AwardWatchRepository) {} + + execute(userId: string): Promise { + return this.watches.findByUserId(userId); + } +} + +export class DeleteAwardWatch { + constructor(private readonly watches: AwardWatchRepository) {} + + async execute(userId: string, watchId: string): Promise { + const watch = await this.watches.findById(watchId); + // Same never-distinguishable contract as accounts/goals: absent and + // not-yours both read as not found. + if (!watch || watch.userId !== userId) { + throw new AwardWatchNotFoundError(watchId); + } + await this.watches.delete(watchId); + } +} + +/** A watch that fired during a check run, ready for delivery surfaces. */ +export interface AwardWatchHit { + readonly watch: AwardWatch; + readonly bestRealizedCpp: number; + /** Title of the best-value deal found on the page. */ + readonly bestDealTitle: string; + readonly pageTitle: string; +} + +export interface CheckAwardWatchesResult { + readonly checked: number; + readonly failed: number; + readonly hits: AwardWatchHit[]; +} + +/** + * Worker loop: re-scrape every watch's page (via the existing IngestDealPage + * use case, so extraction heuristics live in one place), find the best + * realized ¢/pt among the extracted deals, and fire a hit when the watch's + * threshold is met and the value improves on what was seen before. Scrape + * failures are counted but never abort the run; bookkeeping still advances so + * a permanently broken page doesn't look "never checked". + */ +export class CheckAwardWatches { + constructor( + private readonly watches: AwardWatchRepository, + private readonly ingestDealPage: IngestDealPage, + private readonly clock: Clock = systemClock, + ) {} + + async execute(): Promise { + const all = await this.watches.findAll(); + const hits: AwardWatchHit[] = []; + let failed = 0; + + for (const watch of all) { + const now = this.clock.now(); + let best: { cpp: number; title: string } | null = null; + let pageTitle = ""; + + try { + const result = await this.ingestDealPage.execute({ url: watch.url }); + pageTitle = result.pageTitle; + for (const deal of result.deals) { + const cpp = realizedCpp(deal); + if (cpp !== null && (best === null || cpp > best.cpp)) { + best = { cpp, title: deal.title }; + } + } + } catch { + failed += 1; + await this.watches.update( + recordCheck(watch, { bestRealizedCpp: null, notified: false, now }), + ); + continue; + } + + const notified = shouldNotify(watch, best?.cpp ?? null); + if (notified && best) { + hits.push({ + watch, + bestRealizedCpp: best.cpp, + bestDealTitle: best.title, + pageTitle, + }); + } + await this.watches.update( + recordCheck(watch, { + bestRealizedCpp: best?.cpp ?? null, + notified, + now, + }), + ); + } + + return { checked: all.length, failed, hits }; + } +} diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts index 158c812..197ce70 100644 --- a/packages/core/src/contracts/index.ts +++ b/packages/core/src/contracts/index.ts @@ -162,6 +162,26 @@ export const setCustomValuationRequestSchema = z }) .strict(); +export const awardWatchDtoSchema = z.object({ + id: z.string(), + url: z.url(), + label: z.string(), + minCentsPerPoint: z.number().positive(), + bestSeenCentsPerPoint: z.number().positive().nullable(), + lastCheckedAt: isoDateTimeSchema.nullable(), + lastNotifiedAt: isoDateTimeSchema.nullable(), + createdAt: isoDateTimeSchema, +}); + +export const createAwardWatchRequestSchema = z + .object({ + url: z.url(), + label: z.string().min(1).max(120), + /** Notify when a scraped deal reaches this realized ¢/pt (0 < v ≤ 100). */ + minCentsPerPoint: z.number().positive().max(100), + }) + .strict(); + export const recordManualBalanceRequestSchema = z .object({ points: z.number().int().nonnegative(), @@ -259,6 +279,8 @@ export const HTTP_STATUS_BY_ERROR_CODE = { PROVIDER_NOT_SUPPORTED: 422, INVALID_MEMBERSHIP_NUMBER: 422, INVALID_VALUATION: 422, + INVALID_AWARD_WATCH: 422, + AWARD_WATCH_NOT_FOUND: 404, INVALID_BALANCE: 422, INVALID_CAPTURE_TIME: 422, INVALID_GOAL_TITLE: 422, @@ -310,6 +332,10 @@ export type BulkUpdateMembershipResultDto = z.infer< typeof bulkUpdateMembershipResultDtoSchema >; export type CustomValuationDto = z.infer; +export type AwardWatchDto = z.infer; +export type CreateAwardWatchRequest = z.infer< + typeof createAwardWatchRequestSchema +>; export type SetCustomValuationRequest = z.infer< typeof setCustomValuationRequestSchema >; @@ -833,3 +859,25 @@ export function toCustomValuationDto(valuation: { updatedAt: valuation.updatedAt.toISOString(), }; } + +export function toAwardWatchDto(watch: { + readonly id: string; + readonly url: string; + readonly label: string; + readonly minCentsPerPoint: number; + readonly bestSeenCentsPerPoint: number | null; + readonly lastCheckedAt: Date | null; + readonly lastNotifiedAt: Date | null; + readonly createdAt: Date; +}): AwardWatchDto { + return { + id: watch.id, + url: watch.url, + label: watch.label, + minCentsPerPoint: watch.minCentsPerPoint, + bestSeenCentsPerPoint: watch.bestSeenCentsPerPoint, + lastCheckedAt: watch.lastCheckedAt?.toISOString() ?? null, + lastNotifiedAt: watch.lastNotifiedAt?.toISOString() ?? null, + createdAt: watch.createdAt.toISOString(), + }; +} diff --git a/packages/core/src/contracts/openapi.ts b/packages/core/src/contracts/openapi.ts index ce86b2e..67ee1bc 100644 --- a/packages/core/src/contracts/openapi.ts +++ b/packages/core/src/contracts/openapi.ts @@ -10,6 +10,8 @@ import { chatAssistantResponseSchema, createPortfolioShareRequestSchema, createTripGoalRequestSchema, + awardWatchDtoSchema, + createAwardWatchRequestSchema, customValuationDtoSchema, deletedAccountDtoSchema, setCustomValuationRequestSchema, @@ -63,6 +65,8 @@ const COMPONENT_SCHEMAS = { ImportPortfolioResultDto: importPortfolioResultDtoSchema, BulkUpdateMembershipResultDto: bulkUpdateMembershipResultDtoSchema, CustomValuationDto: customValuationDtoSchema, + AwardWatchDto: awardWatchDtoSchema, + CreateAwardWatchRequest: createAwardWatchRequestSchema, SetCustomValuationRequest: setCustomValuationRequestSchema, ApiError: apiErrorSchema, LinkLoyaltyAccountRequest: linkLoyaltyAccountRequestSchema, @@ -482,6 +486,34 @@ export function buildOpenApiDocument(options: BuildOpenApiOptions = {}): Json { }, }, }, + "/api/v1/watches": { + get: { + summary: "List award watches", + responses: { + "200": jsonResponse("Award watches", arrayOf("AwardWatchDto")), + ...ERROR_RESPONSES, + }, + }, + post: { + summary: "Watch an award/deal page for value improvements", + requestBody: body("CreateAwardWatchRequest"), + responses: { + "201": jsonResponse("Created watch", ref("AwardWatchDto")), + ...ERROR_RESPONSES, + }, + }, + }, + "/api/v1/watches/{id}": { + parameters: [ID_PARAM], + delete: { + summary: "Stop watching a page", + responses: { + "204": { description: "Deleted" }, + ...ERROR_RESPONSES, + ...NOT_FOUND, + }, + }, + }, "/api/v1/loyalty-accounts/deleted": { get: { summary: "Recently unlinked accounts (restore window)", diff --git a/packages/core/src/domain/errors.ts b/packages/core/src/domain/errors.ts index 1018a6a..c5b533a 100644 --- a/packages/core/src/domain/errors.ts +++ b/packages/core/src/domain/errors.ts @@ -84,6 +84,22 @@ export class TripGoalNotFoundError extends DomainError { } } +export class AwardWatchNotFoundError extends DomainError { + readonly code = "AWARD_WATCH_NOT_FOUND"; + + constructor(watchId: string) { + super(`Award watch "${watchId}" was not found`); + } +} + +export class InvalidAwardWatchError extends DomainError { + readonly code = "INVALID_AWARD_WATCH"; + + constructor(message: string) { + super(message); + } +} + export class InvalidGoalTitleError extends DomainError { readonly code = "INVALID_GOAL_TITLE"; diff --git a/packages/core/src/domain/loyalty/award-watch.ts b/packages/core/src/domain/loyalty/award-watch.ts new file mode 100644 index 0000000..b59e930 --- /dev/null +++ b/packages/core/src/domain/loyalty/award-watch.ts @@ -0,0 +1,127 @@ +import { InvalidAwardWatchError, InvalidScrapeUrlError } from "../errors"; + +/** + * A watched award/deal page. The worker re-scrapes it on a schedule and + * notifies the user when a redemption at or above their cents-per-point + * threshold appears — and again only when the best seen value improves, so + * a standing good deal doesn't ping every day. + */ +export interface AwardWatch { + readonly id: string; + readonly userId: string; + /** The award-chart / deal page to re-scrape. */ + readonly url: string; + readonly label: string; + /** Notify when a scraped deal's realized ¢/pt reaches this value. */ + readonly minCentsPerPoint: number; + /** Best realized ¢/pt seen so far; null until the first qualifying hit. */ + readonly bestSeenCentsPerPoint: number | null; + readonly lastCheckedAt: Date | null; + readonly lastNotifiedAt: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface NewAwardWatch { + readonly userId: string; + readonly url: string; + readonly label: string; + readonly minCentsPerPoint: number; + readonly id?: string; + readonly now?: Date; +} + +const MAX_LABEL_LENGTH = 120; +const MAX_CENTS_PER_POINT = 100; + +function assertHttpUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new InvalidScrapeUrlError(); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new InvalidScrapeUrlError(); + } + return parsed.toString(); +} + +/** Factory enforcing the entity's invariants. */ +export function createAwardWatch(input: NewAwardWatch): AwardWatch { + const label = input.label.trim(); + if (label.length === 0 || label.length > MAX_LABEL_LENGTH) { + throw new InvalidAwardWatchError( + `Label must be 1-${MAX_LABEL_LENGTH} characters`, + ); + } + if ( + !Number.isFinite(input.minCentsPerPoint) || + input.minCentsPerPoint <= 0 || + input.minCentsPerPoint > MAX_CENTS_PER_POINT + ) { + throw new InvalidAwardWatchError( + `Threshold must be greater than 0 and at most ${MAX_CENTS_PER_POINT} cents per point`, + ); + } + + const now = input.now ?? new Date(); + return { + id: input.id ?? crypto.randomUUID(), + userId: input.userId, + url: assertHttpUrl(input.url.trim()), + label, + minCentsPerPoint: input.minCentsPerPoint, + bestSeenCentsPerPoint: null, + lastCheckedAt: null, + lastNotifiedAt: null, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Pure decision: given a check's best realized ¢/pt, does this watch fire? + * Fires when the threshold is met AND the value improves on the best already + * seen (first qualifying hit always fires). + */ +export function shouldNotify( + watch: AwardWatch, + bestRealizedCpp: number | null, +): boolean { + if (bestRealizedCpp === null) return false; + if (bestRealizedCpp < watch.minCentsPerPoint) return false; + return ( + watch.bestSeenCentsPerPoint === null || + bestRealizedCpp > watch.bestSeenCentsPerPoint + ); +} + +/** Record a check outcome (immutably), advancing bookkeeping fields. */ +export function recordCheck( + watch: AwardWatch, + outcome: { bestRealizedCpp: number | null; notified: boolean; now: Date }, +): AwardWatch { + return { + ...watch, + bestSeenCentsPerPoint: + outcome.bestRealizedCpp !== null && + (watch.bestSeenCentsPerPoint === null || + outcome.bestRealizedCpp > watch.bestSeenCentsPerPoint) + ? outcome.bestRealizedCpp + : watch.bestSeenCentsPerPoint, + lastCheckedAt: outcome.now, + lastNotifiedAt: outcome.notified ? outcome.now : watch.lastNotifiedAt, + updatedAt: outcome.now, + }; +} + +export interface AwardWatchRepository { + findById(id: string): Promise; + findByUserId(userId: string): Promise; + /** Every watch across all users — the worker's check loop. */ + findAll(): Promise; + insert(watch: AwardWatch): Promise; + update(watch: AwardWatch): Promise; + delete(id: string): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2612ecc..b3edf7d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from "./domain/loyalty/repositories"; export * from "./domain/loyalty/trip-goal"; export * from "./domain/loyalty/portfolio-share"; export * from "./domain/loyalty/custom-valuation"; +export * from "./domain/loyalty/award-watch"; // Application export * from "./application/ports"; @@ -17,6 +18,7 @@ export * from "./application/loyalty/list-loyalty-accounts"; export * from "./application/loyalty/get-loyalty-account"; export * from "./application/loyalty/update-loyalty-account"; export * from "./application/loyalty/custom-valuations"; +export * from "./application/loyalty/award-watches"; export * from "./application/loyalty/bulk-update-membership"; export * from "./application/loyalty/restore-loyalty-account"; export * from "./application/loyalty/get-balance-history"; @@ -52,6 +54,7 @@ export * from "./infrastructure/db/client"; export * as dbSchema from "./infrastructure/db/schema"; export * from "./infrastructure/repositories/drizzle-loyalty-account-repository"; export * from "./infrastructure/repositories/drizzle-custom-valuation-repository"; +export * from "./infrastructure/repositories/drizzle-award-watch-repository"; export * from "./infrastructure/providers/composite-travel-provider-gateway"; export * from "./infrastructure/providers/simulated-travel-provider-gateway"; export * from "./infrastructure/providers/http-aggregator-travel-provider-gateway"; diff --git a/packages/core/src/infrastructure/db/schema.ts b/packages/core/src/infrastructure/db/schema.ts index 5bbc4f3..d1e2a61 100644 --- a/packages/core/src/infrastructure/db/schema.ts +++ b/packages/core/src/infrastructure/db/schema.ts @@ -155,6 +155,27 @@ export const userProviderValuations = pgTable( (row) => [primaryKey({ columns: [row.userId, row.providerId] })], ); +// ─── Award watchlist ─────────────────────────────────────────────────────── +// Watched award/deal pages, re-scraped on a schedule. Cents-per-point values +// are stored as integer milli-cents (× 1000) like custom valuations. + +export const awardWatches = pgTable( + "award_watch", + { + id: varchar("id", { length: 255 }).notNull().primaryKey(), + userId: varchar("user_id", { length: 255 }).notNull(), + url: varchar("url", { length: 2048 }).notNull(), + label: varchar("label", { length: 120 }).notNull(), + minCentsPerPointMilli: integer("min_cents_per_point_milli").notNull(), + bestSeenCentsPerPointMilli: integer("best_seen_cents_per_point_milli"), + lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }), + lastNotifiedAt: timestamp("last_notified_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (watch) => [index("award_watch_user_id_idx").on(watch.userId)], +); + // ─── Public portfolio shares ─────────────────────────────────────────────── export const portfolioShares = pgTable( diff --git a/packages/core/src/infrastructure/repositories/drizzle-award-watch-repository.ts b/packages/core/src/infrastructure/repositories/drizzle-award-watch-repository.ts new file mode 100644 index 0000000..644f78e --- /dev/null +++ b/packages/core/src/infrastructure/repositories/drizzle-award-watch-repository.ts @@ -0,0 +1,92 @@ +import { desc, eq } from "drizzle-orm"; + +import type { + AwardWatch, + AwardWatchRepository, +} from "../../domain/loyalty/award-watch"; +import type { Database } from "../db/client"; +import { awardWatches } from "../db/schema"; + +/** Cents-per-point values persist as integer milli-cents (× 1000). */ +const MILLI = 1000; + +type Row = typeof awardWatches.$inferSelect; + +function toDomain(row: Row): AwardWatch { + return { + id: row.id, + userId: row.userId, + url: row.url, + label: row.label, + minCentsPerPoint: row.minCentsPerPointMilli / MILLI, + bestSeenCentsPerPoint: + row.bestSeenCentsPerPointMilli === null + ? null + : row.bestSeenCentsPerPointMilli / MILLI, + lastCheckedAt: row.lastCheckedAt, + lastNotifiedAt: row.lastNotifiedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toRow(watch: AwardWatch): Row { + return { + id: watch.id, + userId: watch.userId, + url: watch.url, + label: watch.label, + minCentsPerPointMilli: Math.round(watch.minCentsPerPoint * MILLI), + bestSeenCentsPerPointMilli: + watch.bestSeenCentsPerPoint === null + ? null + : Math.round(watch.bestSeenCentsPerPoint * MILLI), + lastCheckedAt: watch.lastCheckedAt, + lastNotifiedAt: watch.lastNotifiedAt, + createdAt: watch.createdAt, + updatedAt: watch.updatedAt, + }; +} + +export class DrizzleAwardWatchRepository implements AwardWatchRepository { + constructor(private readonly db: Database) {} + + async findById(id: string): Promise { + const rows = await this.db + .select() + .from(awardWatches) + .where(eq(awardWatches.id, id)) + .limit(1); + return rows[0] ? toDomain(rows[0]) : null; + } + + async findByUserId(userId: string): Promise { + const rows = await this.db + .select() + .from(awardWatches) + .where(eq(awardWatches.userId, userId)) + .orderBy(desc(awardWatches.createdAt)); + return rows.map(toDomain); + } + + async findAll(): Promise { + const rows = await this.db.select().from(awardWatches); + return rows.map(toDomain); + } + + async insert(watch: AwardWatch): Promise { + await this.db.insert(awardWatches).values(toRow(watch)); + } + + async update(watch: AwardWatch): Promise { + const { id, ...rest } = toRow(watch); + await this.db + .update(awardWatches) + .set(rest) + .where(eq(awardWatches.id, id)); + } + + async delete(id: string): Promise { + await this.db.delete(awardWatches).where(eq(awardWatches.id, id)); + } +} diff --git a/packages/core/test/award-watches.test.ts b/packages/core/test/award-watches.test.ts new file mode 100644 index 0000000..91f32a8 --- /dev/null +++ b/packages/core/test/award-watches.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { + CheckAwardWatches, + CreateAwardWatch, + DeleteAwardWatch, +} from "../src/application/loyalty/award-watches"; +import { IngestDealPage } from "../src/application/loyalty/ingest-deal-page"; +import { + createAwardWatch, + recordCheck, + shouldNotify, +} from "../src/domain/loyalty/award-watch"; +import { + AwardWatchNotFoundError, + InvalidAwardWatchError, + InvalidScrapeUrlError, +} from "../src/domain/errors"; +import type { PageScraper, ScrapedPage } from "../src/application/ports"; +import { InMemoryAwardWatchRepository } from "./fakes"; + +const NOW = new Date("2026-07-09T12:00:00Z"); +const clock = { now: () => NOW }; + +function watch(over: Partial[0]> = {}) { + return createAwardWatch({ + userId: "u1", + url: "https://blog.example/hyatt-sweet-spots", + label: "Hyatt sweet spots", + minCentsPerPoint: 2, + now: NOW, + ...over, + }); +} + +/** Scraper whose markdown yields one deal at a chosen ¢/pt. */ +function scraperAt(cpp: number): PageScraper { + // 10,000 points for $ (cpp * 100) → realized cpp as requested. + const dollars = (cpp * 10_000) / 100; + const page: ScrapedPage = { + url: "https://blog.example/hyatt-sweet-spots", + title: "Sweet spots", + markdown: `Park Hyatt: 10,000 points instead of $${dollars.toFixed(2)} at Hyatt`, + fetchedAt: NOW, + }; + return { scrape: async () => page }; +} + +const failingScraper: PageScraper = { + scrape: async () => { + throw new Error("blocked"); + }, +}; + +describe("createAwardWatch / shouldNotify / recordCheck", () => { + it("validates label, threshold, and url", () => { + expect(() => watch({ label: " " })).toThrow(InvalidAwardWatchError); + expect(() => watch({ minCentsPerPoint: 0 })).toThrow(InvalidAwardWatchError); + expect(() => watch({ minCentsPerPoint: 101 })).toThrow(InvalidAwardWatchError); + expect(() => watch({ url: "ftp://x" })).toThrow(InvalidScrapeUrlError); + expect(() => watch({ url: "not a url" })).toThrow(InvalidScrapeUrlError); + }); + + it("fires on the first qualifying value, then only on improvement", () => { + let w = watch(); // threshold 2¢/pt + expect(shouldNotify(w, null)).toBe(false); + expect(shouldNotify(w, 1.5)).toBe(false); // below threshold + expect(shouldNotify(w, 2.4)).toBe(true); // first hit + + w = recordCheck(w, { bestRealizedCpp: 2.4, notified: true, now: NOW }); + expect(w.bestSeenCentsPerPoint).toBe(2.4); + expect(w.lastNotifiedAt).toEqual(NOW); + + expect(shouldNotify(w, 2.4)).toBe(false); // same value: no re-ping + expect(shouldNotify(w, 2.2)).toBe(false); // worse: no ping + expect(shouldNotify(w, 3.1)).toBe(true); // improvement: ping again + }); +}); + +describe("CheckAwardWatches", () => { + it("fires a hit at/above threshold and advances bookkeeping", async () => { + const repo = new InMemoryAwardWatchRepository(); + await repo.insert(watch()); + const check = new CheckAwardWatches( + repo, + new IngestDealPage(scraperAt(2.5), clock), + clock, + ); + + const first = await check.execute(); + expect(first).toMatchObject({ checked: 1, failed: 0 }); + expect(first.hits).toHaveLength(1); + expect(first.hits[0]!.bestRealizedCpp).toBe(2.5); + + // Same page again → no new hit (no improvement), but still checked. + const second = await check.execute(); + expect(second.hits).toHaveLength(0); + const stored = (await repo.findAll())[0]!; + expect(stored.bestSeenCentsPerPoint).toBe(2.5); + expect(stored.lastCheckedAt).toEqual(NOW); + }); + + it("stays quiet below the threshold", async () => { + const repo = new InMemoryAwardWatchRepository(); + await repo.insert(watch({ minCentsPerPoint: 3 })); + const check = new CheckAwardWatches( + repo, + new IngestDealPage(scraperAt(2.5), clock), + clock, + ); + expect((await check.execute()).hits).toHaveLength(0); + }); + + it("counts scrape failures without aborting and still marks the check", async () => { + const repo = new InMemoryAwardWatchRepository(); + await repo.insert(watch()); + const check = new CheckAwardWatches( + repo, + new IngestDealPage(failingScraper, clock), + clock, + ); + const result = await check.execute(); + expect(result).toMatchObject({ checked: 1, failed: 1 }); + expect((await repo.findAll())[0]!.lastCheckedAt).toEqual(NOW); + }); +}); + +describe("CreateAwardWatch / DeleteAwardWatch", () => { + it("creates then deletes an owned watch; never another user's", async () => { + const repo = new InMemoryAwardWatchRepository(); + const created = await new CreateAwardWatch(repo, clock).execute({ + userId: "u1", + url: "https://blog.example/deals", + label: "Deals", + minCentsPerPoint: 1.8, + }); + expect((await repo.findByUserId("u1"))[0]?.id).toBe(created.id); + + const del = new DeleteAwardWatch(repo); + await expect(del.execute("u2", created.id)).rejects.toBeInstanceOf( + AwardWatchNotFoundError, + ); + await del.execute("u1", created.id); + expect(await repo.findByUserId("u1")).toEqual([]); + }); +}); diff --git a/packages/core/test/fakes.ts b/packages/core/test/fakes.ts index 0293509..96f5dde 100644 --- a/packages/core/test/fakes.ts +++ b/packages/core/test/fakes.ts @@ -6,6 +6,10 @@ import type { CustomValuation, CustomValuationRepository, } from "../src/domain/loyalty/custom-valuation"; +import type { + AwardWatch, + AwardWatchRepository, +} from "../src/domain/loyalty/award-watch"; import type { TripGoal } from "../src/domain/loyalty/trip-goal"; import type { ActivityEventRepository, @@ -244,3 +248,31 @@ export class InMemoryCustomValuationRepository this.rows.delete(this.key(userId, providerId)); } } + +export class InMemoryAwardWatchRepository implements AwardWatchRepository { + readonly rows = new Map(); + + async findById(id: string): Promise { + return this.rows.get(id) ?? null; + } + + async findByUserId(userId: string): Promise { + return [...this.rows.values()].filter((w) => w.userId === userId); + } + + async findAll(): Promise { + return [...this.rows.values()]; + } + + async insert(watch: AwardWatch): Promise { + this.rows.set(watch.id, watch); + } + + async update(watch: AwardWatch): Promise { + this.rows.set(watch.id, watch); + } + + async delete(id: string): Promise { + this.rows.delete(id); + } +}