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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ TypeScript · AWS Lambda (Node.js, arm64) · EventBridge (Scheduler and event bu
Work in progress: the diagram shows the target architecture, built one step at a time.

- Done: TypeScript project, required pull request checks (lint, types, unit tests, commitlint, Gitleaks, CodeQL), Dependabot updates, an empty CDK app synthesized on each pull request with cdk-nag rules, GitHub OIDC roles, a `cdk diff` of each pull request and a `cdk deploy` on each merge to `main`.
- Next: the Lambdas, with the smoke test and the `prod` alias rollback.
- Done: the Treize Mondial adapter, the event bus and the `fetch-results` Lambda, which publishes `MatchFinished` once a match is finished. Nothing invokes it or consumes its events yet.
- Next: `notify`, the planner and its one-time schedules, then the DLQ, the alarms, the smoke test and the `prod` alias rollback.

Progress is tracked in the [v1 milestone](https://github.com/TomSoulage/final-whistle/milestone/1).

Expand Down
2 changes: 2 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Log of the project's structural choices. Each decision has a stable identifier (
- **D7. At-least-once delivery: send, then record.** `notify` checks DynamoDB, sends to Telegram, then records the score. A crash between sending and recording means a duplicate message, never a lost score. A rare duplicate is accepted.
- **D8. Idempotence key**: the page date and both teams, e.g. `2026-10-30|Angleterre|Papouasie-Nouvelle-Guinée`. Team names are kept exactly as published.
- **D9. Failed events go to a DLQ.** After Lambda's retries, a failing event lands in an SQS dead-letter queue, watched by an alarm, instead of being lost silently.
- **D28. The `MatchFinished` contract.** `fetch-results` takes the match key `{ date, homeTeam, awayTeam }` (D8) as input and validates it first: a malformed key fails at once instead of being looked up and rescheduled. A finished match is published on the `final-whistle` bus as `MatchFinished` (source `final-whistle.fetch-results`); its detail is the normalised result plus its match id, so consumers never rebuild the key. The bus is created with its producer and each consumer adds its own rule (D6). Source, type and detail are defined once in `src/events`, for the producer and the rules. PutEvents reports a rejected entry without throwing, so a rejection is turned into an error: the invocation fails and is retried instead of losing the score.

### Data and secrets

Expand Down Expand Up @@ -55,6 +56,7 @@ Log of the project's structural choices. Each decision has a stable identifier (

- **D20. Toolchain: npm, TypeScript 6.0, ESLint, Vitest, commitlint, tsx.** tsx runs the CDK app, as `cdk init` sets it up. Node 24 (`.nvmrc`). TypeScript stays on 6.0 because typescript-eslint does not support 7.x yet. ESLint enforces the measurable code rules of `CLAUDE.md`. Commitlint checks the PR title, since it becomes the squash commit, as well as each commit. Actions are restricted to GitHub-owned ones plus an explicit allowlist in the repository settings (currently `gitleaks/gitleaks-action` and `aws-actions/configure-aws-credentials`), and must be pinned to a full commit SHA; Dependabot keeps them up to date. A new third-party action needs an allowlist entry first.
- **D21. Dependabot sorts updates by risk.** Minor and patch updates are grouped into one weekly pull request, which should stay green. Each major update gets its own pull request, so a breaking one never blocks the safe ones. New versions are proposed only after a 7-day cooldown, to avoid compromised releases that get pulled within days. Two majors are ignored because their version is a decision, not a default: `typescript` (revisit when typescript-eslint supports 7.x) and `@types/node` (revisit when the Lambda Node.js runtime changes).
- **D29. Structured logs without a library.** An in-house JSON logger (one object per line on stdout) rather than Powertools: a few lines, no dependency. The context (Lambda name, match id) is on every line and cannot be overwritten by a field. A failure is logged with its chain of causes, then rethrown so that Lambda retries it. Each Lambda has its own log group, created by the stack with a one-month retention.

---

Expand Down
49 changes: 49 additions & 0 deletions infra/fetch-results-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { fileURLToPath } from 'node:url';
import { Duration } from 'aws-cdk-lib';
import type { EventBus } from 'aws-cdk-lib/aws-events';
import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import { EVENT_BUS_NAME_VARIABLE } from '../src/fetch-results/environment.js';

const HANDLER_ENTRY = fileURLToPath(new URL('../src/fetch-results/handler.ts', import.meta.url));
// Up to two pages at 10 s each, plus parsing and publishing.
const TIMEOUT_SECONDS = 30;
export const LOG_RETENTION = RetentionDays.ONE_MONTH;

export interface FetchResultsFunctionProps {
readonly eventBus: EventBus;
}

/** The fetch-results Lambda, with its own least-privilege role and its own log group. */
export class FetchResultsFunction extends Construct {
readonly lambdaFunction: NodejsFunction;

constructor(scope: Construct, id: string, props: FetchResultsFunctionProps) {
super(scope, id);
const logGroup = new LogGroup(this, 'LogGroup', { retention: LOG_RETENTION });
const role = new Role(this, 'Role', {
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
description: 'fetch-results Lambda: write its logs and publish MatchFinished',
});
role.addToPolicy(
new PolicyStatement({
actions: ['logs:CreateLogStream', 'logs:PutLogEvents'],
resources: [logGroup.logGroupArn],
}),
);
this.lambdaFunction = new NodejsFunction(this, 'Function', {
entry: HANDLER_ENTRY,
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
timeout: Duration.seconds(TIMEOUT_SECONDS),
role,
logGroup,
environment: { [EVENT_BUS_NAME_VARIABLE]: props.eventBus.eventBusName },
});
props.eventBus.grantPutEventsTo(this.lambdaFunction);
}
}
16 changes: 14 additions & 2 deletions infra/final-whistle-stack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { Stack } from 'aws-cdk-lib';
import { Stack, type StackProps } from 'aws-cdk-lib';
import { EventBus } from 'aws-cdk-lib/aws-events';
import type { Construct } from 'constructs';
import { FetchResultsFunction } from './fetch-results-function.js';

export const EVENT_BUS_NAME = 'final-whistle';

/** Application stack: the Lambdas, bus, table and alarms are added to it step by step. */
export class FinalWhistleStack extends Stack {}
export class FinalWhistleStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Created with its producer; consumers subscribe with their own rules (D6).
const eventBus = new EventBus(this, 'EventBus', { eventBusName: EVENT_BUS_NAME });
new FetchResultsFunction(this, 'FetchResults', { eventBus });
}
}
Loading
Loading