* feat(menu): publish the weekly menu from the job worker
Monday publish parses the catalog embedded in the Redefine menu page and runs on the Fargate worker, so the GitHub Actions scrape cron can go away.
* fix(menu): address review feedback
Use the form deadline in the Monday Slack post, and compare that message exactly so CodeQL does not treat the test as URL sanitization.
* feat(infra): export attached VPC ids and lock prod to afterhours (DEV-289)
Prod must keep existing_vpc_id pointed at the afterhours VPC. Outputs
expose the resolved vpc_id and public subnet IDs.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* feat(api): add OpenAPI 3.1 and Redocly lint in CI (DEV-289)
Same extends: recommended ruleset and @redocly/cli 2.52.1 as
internal-portal. Documents current { error: string } JSON errors.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(api): document 4xx and reject invalid form-status weeks (DEV-289)
Health, form-status, and roster document 400. form-status now maps
current and returns 400 for a week that is not current or YYYY-WNN.
Redocly treats 302 as a success response, matching the portal.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* style(test): format VPC contract assertions for ruff (DEV-289)
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(api): fail Redocly on missing 4xx and 2xx/3xx (DEV-289)
Promote operation-4xx-response and the 2xx-or-3xx success rule to error.
Replace unused health and roster 400s with 403, matching portal health.
Form-status keeps its real 400 for invalid week.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(api): split week params and allow live menu nulls (DEV-289)
Menu and form-status take current or YYYY-WNN. Orders take YYYY-WNN or a
calendar date and reject current. Menu payloads may emit null menu_url,
calories, protein, and image_url.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(infra): fail prod apply without the afterhours VPC (DEV-289)
Prod never creates the 10.60 fallback VPC. A terraform_data precondition
fails plan and apply when existing_vpc_id is empty, instead of a check
block that only warns.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
Initialize the SDK on gunicorn and the SQS worker with afterhours-style scrubbing. Store the DSN in SSM and inject only the parameter name onto the live task.
* feat(api): serve meals on ECS Fargate instead of Lambda
Keep the Flask app always-on with in-process jobs so CloudFront no longer fronts a cold-start API Gateway.
* fix(jobs): run delayed close and reminder deliveries
Wall-clock skip windows dropped the only weekly SQS attempt when Scheduler already fired in Eastern time. Dev schedules stay disabled.
* fix(api): return JSON objects and stop logging job payloads
Flask now jsonify-s handler dicts so API responses are not HTML, and the worker logs only event and status.
* fix(ci): restore the reusable workflow so the required check is named ci / ci
Inlining the job reported `ci` instead of the org ruleset's `ci / ci`.
* fix(secrets): drop unused os import so ruff check passes
* style: apply ruff format so ci-python-app lint passes
* fix(infra): give meals its own VPC because prod has none
* chore(security): re-key ALB SG checkov suppression after vpc.tf
* fix(auth): accept federated portal Cognito tokens for meals admin
Google Workspace federation stores email_verified=false, which 403'd the
portal Admin probe while the public menu still loaded.
* fix(iam): grant plan role CloudFront DescribeFunction
Helvetica cannot encode an em dash, so the header rendered a question mark. Tighter single-column spacing keeps a 12-person week on page 1 while still paginating large weeks.
* feat(api): add IAM-authenticated menu publication
Keep weekly menu writes behind Lambda so the GitHub runtime role cannot access the shared DynamoDB table directly.
* refactor(workflow): publish weekly menus through API
Use SigV4 requests for settings and menu publication so the scheduled workflow no longer needs direct DynamoDB access.
* fix: address review comments
* style(python): apply Ruff formatting
* fix(auth): require Google authentication in cloud mode
Remove the public shared-key mechanism and fail closed on Google auth while adding submit-route throttling.
* chore(form): lint template JavaScript in CI
* docs(form): record frontend delivery decisions
* fix: resolve remaining merge conflicts
* fix(auth): require Google authentication in cloud mode
Remove the public shared-key mechanism and fail closed on Google auth while adding submit-route throttling.
* fix(auth): address review follow-ups
Fail closed on whitespace-only Google configuration and centralize shared authentication behavior.
* test(auth): use non-secret Google client fixture
Make the public test identifier explicit so secret scanning does not misclassify it as an API key.
* test(auth): avoid OAuth-shaped fixture
Use a format-neutral audience value so secret scanning can distinguish the fixture from a real client identifier.
* chore(security): suppress public OAuth fixture
Document the scanner false positive without suppressing any runtime credential flow.
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
* fix(form): wrap Google user bar at phone widths
Allow the signed-in Google identity and admin controls to wrap below 480px while preserving the desktop row, with generated-form Playwright coverage for phone widths.
* fix(form): preserve 480px user bar boundary
Keep the mobile layout below 480px and lock both sides of the breakpoint with browser coverage.
* style(tests): apply ruff formatting
* test(form): guarantee Playwright browser cleanup
* test(form): strengthen phone-width coverage
* fix: add @classmethod and use cls in tests/test_generate_form.py
Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
* fix(tests): restore class fixture discovery
---------
Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
* refactor(form): extract Jinja templates and lock form JS in CI
Split the monolithic generate_form f-string into form.html.j2/css/js
plus admin.js, inject a single window.CONFIG blob, and add structural
plus Playwright coverage so qty delegation and clamp stay green in CI.
* Update src/server/generate_form.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* fix(form): isolate admin script bindings
* fix(form): address admin and form review findings
* fix(form): resolve remaining review nitpicks
* ci(workflow): restore required check context
Keep the reusable workflow caller job compatible with the organization-required ci / ci status check.
* fix(form): address remaining review findings
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
* ci: add least-privilege permissions blocks to workflow callers
Resolves code scanning alerts #9 and #11 (actions/missing-workflow-permissions). Both callable workflows only need contents: read; the dependency-review callable already declares it internally, this caps the caller token to match.
* fix: turn off debug mode in Flask app configuration.
Resolves code scanning alert #2 (Flask app is run in debug mode)
* ci: bump reusable workflow pin to f71002a (ruff 0.15.22 pin)
Picks up Sea-Haven-Industries/.github#88, which pins ruff in
ci-python-sam so unpinned installs no longer float to new releases
with changed default rule sets (0.16.0 broke CI with 89 pre-existing
findings). Refs Sea-Haven-Industries/.github#87.
aggregate_orders uploads the weekly PDF/CSVs to S3 and then calls
put_summary(), but put_summary spread the summary dict (which contains
float prices/totals) straight into put_item without Decimal conversion.
boto3 rejects floats (TypeError: Float types are not supported), so the
SUMMARY DynamoDB item was never written for any week (W20-W23).
The summary-PDF download endpoint gates on get_summary(week), so it got
None and returned 404 -- 'No summary PDF for <week> yet' -- even though
the PDF was sitting in S3.
Convert via _to_decimal in put_summary, matching put_order/put_settings.
The weekly summary PDF generated at Thursday close was stored in the
reports bucket with no way to reach it from the UI — admins had to pull
it from S3 manually. Surface it in the admin panel:
- submit_order: GET /api/admin/summary-pdf?week= (admin-gated) returns
a 5-minute presigned URL from the SUMMARY item's stamped PDF key;
404 for weeks that haven't closed.
- template.yaml: REPORTS_BUCKET env var + read-only s3:GetObject on
reports/* for SubmitOrderFunction (needed so the presigned URL is
signed with sufficient permissions).
- generate_form.py: "Download summary PDF" button in the admin header;
explains Thursday-close timing on 404.
- Tests: presign happy path, 404 open week, 400 missing week, 401
unauthenticated.
- README updated.
Generate a per-person weekly summary PDF at Thursday close and store it
alongside the CSV reports, plus a client-side admin download that rolls
orders up into item -> total quantity for bulk ordering.
- shared/pdf.py: build_weekly_summary_pdf() via fpdf2 (pure-Python,
ARM64-safe; first non-boto3 layer dep). Per-person employee -> item ->
quantity, no pricing.
- aggregate_orders: write reports/{week}/weekly-summary-{week}.pdf
(application/pdf) and stamp weekly_summary_pdf_s3_key on the SUMMARY.
No new IAM (existing S3CrudPolicy). No email/Slack delivery.
- generate_form.py: "Download order list" admin button aggregates the
loaded week's orders into an item->qty CSV (no per-employee breakdown,
no prices) via a Blob download. Works for open weeks too.
- Tests: tests/test_pdf.py; aggregate happy-path now asserts 3 S3
uploads + the pdf key.
- README updated.
The closed overlay (z-index 2000) was blocking Google sign-in from
firing, so the admin check never ran. Now the overlay is deferred
when Google auth is configured — checkAdmin() shows or bypasses
it after auth completes.
Admins (by email in settings) can now view and submit orders even
after the form closes. The orders-aggregated Slack summary is sent
as a DM to each admin instead of posting to the channel.
* Add admin panel, fix dual-domain auth, harden weekly scrape schedule
Accept both seahavenind.com and seahaven.com Google Workspace domains
for employee sign-in. Add admin panel with order management (view by
week, edit quantities, add/remove items, delete orders) behind Google
auth + DynamoDB admin_emails allowlist. Shift weekly menu scrape from
8:00am to 7:30am ET and add timezone guard to prevent duplicate runs
from dual EST/EDT crons.
* Rename Secrets Manager env vars to avoid CI false positive
The reusable CI workflow greps for keywords like TOKEN and API_KEY in
Lambda environment variables. Our env vars hold Secrets Manager lookup
names, not actual secrets, but the heuristic matched the SM key name
meal-order-manager/slack-bot-token. Rename SLACK_BOT_TOKEN_SECRET to
SLACK_BOT_SM_NAME and FORM_API_KEY_SECRET to FORM_APIKEY_SM_NAME, and
reorder the Globals block so the value falls outside the grep window.
* Add discount settings and two-tier pricing to order aggregation
Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item.
Aggregation now tracks bulk_price and employee_price separately, with
grand_total (company cost) and employee_total (payroll deductions).
* Add Google OAuth, server-side discounts, and Slack order confirmations
Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint,
calculates two-tier discount pricing server-side, and async-invokes the
Slack notifier for per-employee order confirmation DMs. Deadlines updated
to Thursday 11:59pm across all Slack messages.
* Update SAM template for Google auth, Slack invocation, and deadline change
Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order
function with lambda:InvokeFunction policy. Move close-form schedule to
Thursday 11:59pm EST/EDT.
* Update order form UI and CI workflow for new features
Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.
* Add SSM GetParameter permission to submit order Lambda
Required for reading the Google Client ID from Parameter Store
at /meal-order-manager/google-client-id.
* Harden auth, pricing, and reliability in order handlers
Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.
* Fix XSS risks and add closed-form UX to order page
Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.
* Document CORS, cron idempotency, and SSM config in template
Add comments explaining CORS dev server strategy, dual EST/EDT cron
idempotency, and manual SSM parameter creation for Google Client ID.
* Add unit tests for submit, notify, and aggregate handlers
50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.
* Use full email as order slug for defense-in-depth
Replace email-prefix slug with full lowercase email to eliminate any
possibility of cross-domain collisions, per senior review sign-off.
* Remove unused imports flagged by ruff
* Apply ruff formatting
* Fix PR review findings: auth, rounding, and close-form guard
- Remove dead elif branch in submit_order auth (always returned 403)
- Catch HTTPError before URLError so expired tokens return 403 not 503
- Wrap SSM get_parameter in try/except for fresh deployments
- Add wall-clock guard to close_form handler (Friday >= 11 PM ET)
- Add epsilon nudge to JS employeePrice for IEEE 754 boundary match
- Switch Flask dev server from round() to Decimal ROUND_HALF_UP
- Add tests for HTTPError handling and close_form guard (6 new tests)
* Fix close-form weekday guard and SSM auth fail-open
- Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the
crons fire at Thursday 11:59 PM ET, when weekday() is 3
- SSM fail-closed: separate _google_auth_configured() (checks env var) from
_get_google_client_id() (fetches value). If auth is configured but the SSM
fetch fails, return 503 instead of silently falling back to manual auth
- Update close_form tests to use Thursday dates
- Add test_ssm_failure_fails_closed
* Harden Flask dev server auth and escaping
- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json
* fix: Email order filenames, SSM param TTL, DST-safe reopen_at
- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo)
- shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes
- form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta)
- Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* Apply ruff formatting to submit_order handler
* fix(server): retry SSM for Google client id after TTL on failure
Transient SSM errors no longer cache empty client id for the process lifetime;
matches Lambda handler refresh behavior (300s TTL).
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(server): ruff-format Google client id cache helper
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron
EventBridge can deliver past midnight ET; widen the wall-clock guard so a
delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already
closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix(submit-order): bill from Dynamo menu retail, not client JSON
Load authoritative meal prices from get_menu(week); reject unknown meal names
and return 503 when the menu has no priced meals. Use meal_name in the pricing
loop to avoid shadowing the employee name. Adds regression tests for tampering,
unknown meals, and empty menu meals.
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
* fix: use single braces in loadRoster JS nested string
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix Eastern fallback countdown
* Fix pricing validation and JWT display decoding
* Fix optional Google auth detection
* Format app.py line length for ruff compliance
* Fix auth config check and URL escaping in form
- _google_auth_configured() now checks env var presence (intent), not
the fetched SSM value — prevents silent auth bypass if SSM param is
deleted
- Add </script> escaping to URL values in generate_form.py for
consistency with other injected values
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
- Change custom domain from orders.seahavenind.com to
orders.seahaven.com to match other subdomain conventions
- Add GET /api/roster endpoint returning employee names/emails
- Replace name/email text inputs with dropdown populated from
roster API (falls back to embedded roster for local dev)
- Fix order deadline text from Wednesday to Thursday 11:59 PM
- Fix Slack API calls: use form-urlencoded for conversations and
users methods that reject JSON body encoding
conversations.members, conversations.open, and users.info reject
JSON body with 'missing required field'. Use form-urlencoded for
these methods while keeping JSON for chat.postMessage and
files.upload which require it for structured blocks/payloads.
Apply ruff check --fix and ruff format across all Python files to
pass CI pipeline. Remove unused imports (os, sys), fix f-strings
without placeholders. Update README to reflect sync-roster Lambda,
corrected shared layer path, and current project structure.
- Add sync-roster Lambda that auto-syncs employee roster from Slack
channel membership (runs Monday 6:55am ET before menu publish)
- Move FormApiKey from CloudFormation parameter/env var to Secrets
Manager (meal-order-manager/form-api-key) per security conventions
- Add Slack app manifest with required bot scopes
- Add get_channel_members() and get_user_info() to shared Slack module
- Add Lambda function ARN outputs to CloudFormation
- Add log group for sync-roster Lambda (60-day retention)
Move shared layer source from src/shared/python/shared/ to
src/shared/shared/ to prevent SAM from creating a double
python/python/ directory in the layer artifact. Add _to_decimal()
helper to convert floats to Decimal for DynamoDB compatibility
in put_order.
Playwright-based menu scraper for Redefine Meals, self-contained HTML
order form with S3/CloudFront hosting, DynamoDB-backed order submission
via API Gateway, and automated payroll deduction reports via SES.