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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/app/api/v1/watches/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
}
31 changes: 31 additions & 0 deletions apps/web/src/app/api/v1/watches/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
}
11 changes: 11 additions & 0 deletions apps/web/src/server/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
DrizzleTripGoalRepository,
BedrockAssistant,
BulkUpdateMembershipNumbers,
CreateAwardWatch,
DeleteAwardWatch,
DrizzleAwardWatchRepository,
ListAwardWatches,
DeleteCustomValuation,
DrizzleCustomValuationRepository,
ListCustomValuations,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
20 changes: 20 additions & 0 deletions apps/worker/src/container.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {
BuildPortfolioDigest,
CheckAwardWatches,
DrizzleAwardWatchRepository,
FirecrawlPageScraper,
IngestDealPage,
StubPageScraper,
buildTravelProviderGateway,
createDb,
DrizzleBalanceSnapshotRepository,
Expand All @@ -13,6 +18,7 @@ import {
SyncLoyaltyAccount,
type CredentialVault,
type LoyaltyAccountRepository,
type PageScraper,
} from "@pointup/core";

import type { WorkerEnv } from "./env";
Expand All @@ -22,6 +28,7 @@ export interface WorkerContainer {
useCases: {
syncAllLoyaltyAccounts: SyncAllLoyaltyAccounts;
buildPortfolioDigest: BuildPortfolioDigest;
checkAwardWatches: CheckAwardWatches;
};
}

Expand All @@ -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
Expand All @@ -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: {
Expand All @@ -64,6 +80,10 @@ export function createContainer(env: WorkerEnv): WorkerContainer {
listAccounts,
new ListTripGoals(tripGoals, balances),
),
checkAwardWatches: new CheckAwardWatches(
awardWatches,
new IngestDealPage(scraper),
),
},
};
}
4 changes: 4 additions & 0 deletions apps/worker/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 9 additions & 1 deletion apps/worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<void> {
Expand All @@ -29,6 +30,13 @@ async function main(): Promise<void> {
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,
Expand Down
80 changes: 80 additions & 0 deletions apps/worker/src/jobs/check-watches.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, AwardWatchHit[]>();
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),
);
}
}
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .
Expand Down
12 changes: 12 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions docs/bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 43 additions & 1 deletion infra/lib/app-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type {
ActivityEventDto,
AwardWatchDto,
BulkUpdateMembershipRequest,
BulkUpdateMembershipResultDto,
CreateAwardWatchRequest,
CustomValuationDto,
SetCustomValuationRequest,
ApiError,
Expand Down Expand Up @@ -124,6 +126,21 @@ export class PointUpClient {
return this.request("GET", "/api/v1/valuations");
}

listAwardWatches(): Promise<AwardWatchDto[]> {
return this.request("GET", "/api/v1/watches");
}

createAwardWatch(body: CreateAwardWatchRequest): Promise<AwardWatchDto> {
return this.request("POST", "/api/v1/watches", body);
}

deleteAwardWatch(watchId: string): Promise<void> {
return this.request(
"DELETE",
`/api/v1/watches/${encodeURIComponent(watchId)}`,
);
}

setCustomValuation(
providerId: string,
body: SetCustomValuationRequest,
Expand Down
Loading
Loading