Marketing — Banners, Events, League & Course Interest¶
This document covers the marketing surfaces of the Mr. Mentor / MAS backend: the sticky and pop-up banners that ride on top of the public website, the reusable banner-template asset library, the key/value event configuration store, the inter-IIT "League" registration intake (with resume upload), and the lightweight lead-capture forms for course interest and the "AI for Everyone" program. These are mostly thin CRUD + public-read features whose job is to let the admin/marketing team push promotional content to visitors and collect inbound interest.
Status: documented from source on this branch.
Overview¶
The marketing domain is a loosely-coupled collection of small features that share a common shape — an admin-managed catalog plus a public read or public submit endpoint that the marketing website (mas-website-live) and the admin dashboard (mr-mentor-frontend) consume over HTTP.
| Surface | What it does | Primary persona |
|---|---|---|
| Sticky banners | The thin promotional bar pinned to the top of the public site. Admin curates a list, picks one "active" banner via a settings singleton, frontend renders it. | Marketing admin (author) → website visitor (reader) |
| Pop-up banners | Modal/overlay promos on the public site, same model as sticky banners but with an image and four built-in MAS-program templates. | Marketing admin → website visitor |
| Banner templates | A reusable image-asset library (uploaded to S3) tagged by category, used as design starting points. | Marketing admin |
| Event configuration | A generic key/value store for event-related settings (URLs, text, flags) the website reads at runtime — e.g. a registration link that marketing can change without a deploy. | Marketing admin → website |
| League registration | Public intake form for an inter-IIT league/event, including a resume file upload to S3. Admin reviews, exports, and downloads resumes. | Student/visitor (submit) → admin (review) |
| Course interest | Public "I'm interested" capture for courses/cohorts, with admin triage (status, notes, assignment). | Visitor (submit) → sales/marketing (triage) |
| AI for Everyone interest | A dedicated interest-capture form for the "AI for Everyone" program; same pattern as course interest. | Visitor → sales/marketing |
Place in the suite. These features are downstream of the public website and the admin dashboard. They do not participate in auth, payments, or the meeting/token core. The interest-capture forms are adjacent to the Sales CRM (assignedTo, status, lastContactedAt mirror lead-triage semantics) but — importantly — they do not currently create CRM Lead rows or fire any cross-repo events (see Edge cases and Related docs).
Key concepts & entities¶
Glossary
- Active banner — A banner with
isActive = true. The public endpoint, however, does not return all active banners; it resolves a single chosen banner through the settings singleton (see Status lifecycles). - Settings singleton — Each banner type has exactly one settings row (
sticky_banner_settings/popup_banner_settings), loaded withfindOne({ where: {} })and auto-created with defaults on first read. It holds the masterisEnabledtoggle, mobile/desktop visibility, and theactiveBannerIdpointer. - Virtual / system-default banner — Hard-coded, in-memory banner objects with IDs like
SYSTEM_DEFAULT_50orSYSTEM_DEFAULT_FLAGSHIP. They are never persisted; the public resolver returns one whenactiveBannerIdmatches a magic string or when no DB banner is available. isDefaultbanner — A persisted banner flagged as the fallback. Setting one default imperatively unsets all others. Used by the public resolver whenactiveBannerIdresolves to nothing.- Event configuration key — A unique string key (e.g. a registration-URL key) whose value the website fetches in bulk as a
{ key: value }map. - Banner category — Enum (
batch | course | event | general) used to organise template assets and their S3 folder path. - Interest status — Triage state for captured interest (
new | contacted | converted | not_interested).
Main entities (file paths)
| Entity | Table | File |
|---|---|---|
StickyBanner, StickyBannerSettings, BannerDisplayMode (enum) |
sticky_banners, sticky_banner_settings |
src/entities/StickyBanner.ts |
PopupBanner, PopupBannerSettings |
popup_banners, popup_banner_settings |
src/entities/PopupBanner.ts |
BannerTemplate, BannerCategory (enum) |
banner_templates |
src/entities/BannerTemplate.ts |
EventConfiguration, ConfigType (enum) |
event_configurations |
src/entities/EventConfiguration.ts |
LeagueRegistration |
league_registrations |
src/entities/LeagueRegistration.ts |
CourseInterest, InterestStatus, CourseCategory (enums) |
course_interests |
src/entities/CourseInterest.ts |
AiForEveryoneInterest, InterestStatus (enum) |
ai_for_everyone_interest |
src/entities/AiForEveryoneInterest.ts |
Architecture¶
Routes → controllers → services → entities/DB. Sticky and pop-up banners and event config are mounted at /api; course interest is mounted at /api/interest; banner templates live inside the admin router (/api/admin).
flowchart TD
subgraph clients["Clients"]
WEB["mas-website-live (public site)"]
ADMIN["mr-mentor-frontend (admin dashboard)"]
end
subgraph routes["Route layer (src/routes)"]
R1["stickyBanner.routes.ts (/api)"]
R2["popupBanner.routes.ts (/api)"]
R3["admin.routes.ts -> banner templates (/api/admin)"]
R4["eventConfiguration.routes.ts (/api)"]
R5["leagueRegistration.routes.ts (/api)"]
R6["courseInterest.routes.ts (/api/interest)"]
R7["aiForEveryone.routes.ts (/api)"]
end
subgraph controllers["Controllers (src/controllers)"]
C1["StickyBannerController"]
C2["PopupBannerController"]
C3["BannerTemplateController"]
C4["EventConfigurationController"]
C5["LeagueRegistrationController"]
C6["courseInterest.controller (functions)"]
C7["AiForEveryoneController"]
end
subgraph services["Services (src/services)"]
S1["StickyBannerService"]
S2["PopupBannerService"]
S3["BannerTemplateService"]
S4["EventConfigurationService"]
S5["LeagueRegistrationService"]
S7["AiForEveryoneService"]
S3S["S3Service"]
end
subgraph db["PostgreSQL (TypeORM)"]
T1["sticky_banners + settings"]
T2["popup_banners + settings"]
T3["banner_templates"]
T4["event_configurations"]
T5["league_registrations"]
T6["course_interests"]
T7["ai_for_everyone_interest"]
end
S3B["AWS S3 buckets"]
WEB --> R1 & R2 & R4 & R5 & R6 & R7
ADMIN --> R1 & R2 & R3 & R4 & R5 & R7
R1 --> C1 --> S1 --> T1
R2 --> C2 --> S2 --> T2
R3 --> C3 --> S3 --> T3
C3 --> S3S
R4 --> C4 --> S4 --> T4
R5 --> C5 --> S5 --> T5
C5 --> S3S
R6 --> C6 --> T6
R7 --> C7 --> S7 --> T7
S3S --> S3B
S3 --> S3S
Note: courseInterest.controller is a set of plain functions that talk to the repository directly via DatabaseService.getInstance() — it has no dedicated service class.
Data model¶
These tables are independent — there are no foreign-key relationships between any of them. assignedTo on the interest tables and activeBannerId on the settings tables are bare UUID/varchar columns, not TypeORM relations.
erDiagram
STICKY_BANNERS {
uuid id PK
varchar title
varchar subtitle
json chips
varchar buttonText
varchar buttonLink
varchar tagText
varchar backgroundColor
varchar borderColor
varchar buttonColor
varchar textColor
varchar tagColor
int displayOrder
boolean isActive
varchar designTemplate
boolean isDefault
timestamp createdAt
timestamp updatedAt
}
STICKY_BANNER_SETTINGS {
uuid id PK
varchar activeBannerId
enum displayMode
int transitionDuration
boolean isEnabled
boolean showOnMobile
boolean showOnDesktop
}
POPUP_BANNERS {
uuid id PK
varchar title
varchar subtitle
varchar buttonText
varchar buttonLink
varchar imageUrl
varchar backgroundColor
varchar textColor
varchar buttonColor
int displayOrder
boolean isActive
varchar designTemplate
boolean isDefault
}
POPUP_BANNER_SETTINGS {
uuid id PK
varchar activeBannerId
boolean isEnabled
boolean showOnMobile
boolean showOnDesktop
}
BANNER_TEMPLATES {
uuid id PK
varchar name
text description
enum category
varchar imageUrl
varchar s3Key
int width
int height
boolean isActive
simple_array tags
}
EVENT_CONFIGURATIONS {
uuid id PK
varchar key UK
text value
varchar label
enum type
varchar category
boolean isActive
varchar description
}
LEAGUE_REGISTRATIONS {
uuid id PK
varchar fullName
varchar phone
varchar email
varchar college
varchar yearOfPassing
varchar department
varchar domain
jsonb profiles
text resumeUrl
text resumeKey
varchar resumeFileName
}
COURSE_INTERESTS {
uuid id PK
varchar name
varchar email
varchar phone
varchar interestedIn
enum category
enum status
text notes
varchar source
uuid assignedTo
timestamp lastContactedAt
}
AI_FOR_EVERYONE_INTEREST {
uuid id PK
varchar name
varchar email
varchar phone
varchar college
enum status
text notes
varchar source
uuid assignedTo
timestamp lastContactedAt
}
STICKY_BANNER_SETTINGS ||..o| STICKY_BANNERS : "activeBannerId (soft pointer)"
POPUP_BANNER_SETTINGS ||..o| POPUP_BANNERS : "activeBannerId (soft pointer)"
Notable enums & status values
BannerDisplayMode(sticky settings):single | multiple | auto_scroll— defaultsingle.BannerCategory(templates):batch | course | event | general— defaultgeneral.ConfigType(event config):url | text | json | boolean— defaulturl.InterestStatus(course interest + AI interest):new | contacted | converted | not_interested— defaultnew.CourseCategory(course interest):cohort | capsule | accelerator | deeptech | platform— no default (required on create).
The
activeBannerIdpointer is a soft reference (avarchar(36)), not an enforced FK. It can also hold the magic stringsSYSTEM_DEFAULT_*that resolve to in-memory virtual banners rather than DB rows.
API surface¶
Paths below are the full external paths (route-file path + mount prefix from src/routes/index.ts). Banner-template routes additionally require adminMiddleware; all other admin endpoints in this domain are guarded only by authMiddleware (or, for interest forms, nothing — see gotchas).
Sticky banners — StickyBannerRoutes mounted at /api¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| GET | /api/sticky-banners/public |
Public | Resolve the single banner to show + settings (website read) |
| GET | /api/admin/mas/sticky-banners |
authMiddleware |
List all banners |
| GET | /api/admin/mas/sticky-banners/:id |
authMiddleware |
Get one banner |
| POST | /api/admin/mas/sticky-banners |
authMiddleware |
Create banner |
| PUT | /api/admin/mas/sticky-banners/:id |
authMiddleware |
Update banner |
| DELETE | /api/admin/mas/sticky-banners/:id |
authMiddleware |
Delete banner |
| POST | /api/admin/mas/sticky-banners/reorder |
authMiddleware |
Reorder (orderedIds[]) |
| PATCH | /api/admin/mas/sticky-banners/:id/toggle |
authMiddleware |
Toggle isActive |
| GET | /api/admin/mas/sticky-banners-settings |
authMiddleware |
Read settings singleton |
| PUT | /api/admin/mas/sticky-banners-settings |
authMiddleware |
Update settings singleton |
Pop-up banners — PopupBannerRoutes mounted at /api¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| GET | /api/popup-banners/public |
Public | Resolve the single pop-up + settings (website read) |
| GET | /api/admin/mas/popup-banners |
authMiddleware |
List all pop-ups |
| GET | /api/admin/mas/popup-banners/:id |
authMiddleware |
Get one pop-up |
| POST | /api/admin/mas/popup-banners |
authMiddleware |
Create pop-up |
| PUT | /api/admin/mas/popup-banners/:id |
authMiddleware |
Update pop-up |
| DELETE | /api/admin/mas/popup-banners/:id |
authMiddleware |
Delete pop-up |
| POST | /api/admin/mas/popup-banners/reorder |
authMiddleware |
Reorder (orderedIds[]) |
| PATCH | /api/admin/mas/popup-banners/:id/toggle |
authMiddleware |
Toggle isActive |
| GET | /api/admin/mas/popup-banners-settings |
authMiddleware |
Read settings singleton |
| PUT | /api/admin/mas/popup-banners-settings |
authMiddleware |
Update settings singleton |
Banner templates — wired in admin.routes.ts, mounted at /api/admin¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/admin/mas/banner-templates/upload |
authMiddleware + adminMiddleware |
Upload an image (multipart banner) to S3 + create record |
| GET | /api/admin/mas/banner-templates |
authMiddleware + adminMiddleware |
List templates (filters: category, isActive, search, tags) |
| GET | /api/admin/mas/banner-templates/tags |
authMiddleware + adminMiddleware |
Distinct tags of active templates |
| GET | /api/admin/mas/banner-templates/:id |
authMiddleware + adminMiddleware |
Get one template |
| PUT | /api/admin/mas/banner-templates/:id |
authMiddleware + adminMiddleware |
Update metadata |
| DELETE | /api/admin/mas/banner-templates/:id |
authMiddleware + adminMiddleware |
Delete from S3 + DB |
| PATCH | /api/admin/mas/banner-templates/:id/toggle |
authMiddleware + adminMiddleware |
Toggle isActive |
Event configuration — EventConfigurationRoutes mounted at /api¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| GET | /api/event-configurations/public |
Public | Active configs as a { key: value } map (website read) |
| GET | /api/admin/mas/event-configurations |
authMiddleware |
List all configs |
| GET | /api/admin/mas/event-configurations/:key |
authMiddleware |
Get one active config by key |
| POST | /api/admin/mas/event-configurations |
authMiddleware |
Create config |
| PUT | /api/admin/mas/event-configurations/:id |
authMiddleware |
Update config |
| DELETE | /api/admin/mas/event-configurations/:id |
authMiddleware |
Delete config |
League registration — LeagueRegistrationRoutes mounted at /api¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/league-registration |
Public (multipart resume) |
Submit a registration + resume upload |
| GET | /api/admin/league-registrations |
authMiddleware + adminMiddleware |
Paginated list (page, limit) |
| GET | /api/admin/league-registrations/stats |
authMiddleware + adminMiddleware |
Aggregate stats (total, by college, by year) |
| GET | /api/admin/league-registrations/export |
authMiddleware + adminMiddleware |
Export all rows |
| GET | /api/admin/league-registrations/:id |
authMiddleware + adminMiddleware |
Get one registration |
| GET | /api/admin/league-registrations/:id/resume-url |
authMiddleware + adminMiddleware |
Presigned (1h) resume download URL |
| DELETE | /api/admin/league-registrations/:id |
authMiddleware + adminMiddleware |
Delete registration |
Course interest — courseInterestRoutes mounted at /api/interest¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/interest/ |
None (public) | Submit course interest |
| GET | /api/interest/ |
None | Paginated list (filters: status, category) |
| GET | /api/interest/all |
None | List with date-range + search |
| GET | /api/interest/stats |
None | Aggregate counts by category/status/course |
| GET | /api/interest/export |
None | Export (up to 10000 rows) |
| PATCH | /api/interest/:id |
None | Update status/notes/assignedTo |
| DELETE | /api/interest/:id |
None | Delete |
AI for Everyone interest — AiForEveryoneRoutes mounted at /api¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/ai-for-everyone/interest |
None (public) | Submit interest |
| GET | /api/ai-for-everyone/interest |
None | Paginated list |
| GET | /api/ai-for-everyone/interest/stats |
None | Statistics |
| GET | /api/ai-for-everyone/interest/:id |
None | Get one |
| PATCH | /api/ai-for-everyone/interest/:id/status |
None | Update status/notes |
| DELETE | /api/ai-for-everyone/interest/:id |
None | Delete |
The
/api/interest/*and/api/ai-for-everyone/interest/*"admin" reads/writes carry no middleware in the source — the route files note auth is to be "added later". Treat the GET/PATCH/DELETE endpoints there as effectively public today. (See gotchas.)
User journeys¶
Journey 1 — Admin creates and activates a sticky banner from a template¶
The admin uploads an image to the template library, then creates a banner, then flips the settings singleton's activeBannerId so the public site shows it. Note the two-step model: creating a banner with isActive: true is not enough to make it the one the public endpoint returns — the settings pointer (or an isDefault flag) decides.
sequenceDiagram
autonumber
actor Admin
participant FE as Admin dashboard
participant TplC as BannerTemplateController
participant TplS as BannerTemplateService
participant S3 as S3Service
participant StkC as StickyBannerController
participant StkS as StickyBannerService
participant DB as PostgreSQL
Admin->>FE: Upload banner image and fill name plus category
FE->>TplC: POST /api/admin/mas/banner-templates/upload (multipart)
TplC->>TplC: Validate file present and name present
TplC->>S3: uploadBannerTemplate(buffer, name, mime, category)
S3-->>TplC: returns url and s3Key
TplC->>TplS: createBannerTemplate(metadata)
TplS->>DB: INSERT banner_templates
TplS-->>FE: 201 template created
Admin->>FE: Create a sticky banner using that artwork
FE->>StkC: POST /api/admin/mas/sticky-banners (title, colors, buttonLink)
StkC->>StkS: createBanner(body)
StkS->>StkS: Auto-assign displayOrder as max plus 1
alt isDefault is true
StkS->>DB: UPDATE sticky_banners SET isDefault false for all
end
StkS->>DB: INSERT sticky_banners
StkS-->>FE: 201 banner created with new id
Admin->>FE: Set this banner as the active one
FE->>StkC: PUT /api/admin/mas/sticky-banners-settings (activeBannerId = newId, isEnabled true)
StkC->>StkS: updateSettings(body)
StkS->>StkS: getSettings loads or creates the singleton
StkS->>DB: UPDATE sticky_banner_settings
StkS-->>FE: 200 settings saved
Journey 2 — Public website fetches the active banner with mobile/desktop targeting¶
The website calls the public endpoint on every page load. The resolver walks a precedence chain (virtual default → DB pointer → isDefault row → absolute hard-coded fallback) and always returns something. Device targeting is decided by the client using the showOnMobile / showOnDesktop flags returned in settings.
sequenceDiagram
autonumber
participant Web as Public website
participant StkC as StickyBannerController
participant StkS as StickyBannerService
participant DB as PostgreSQL
Web->>StkC: GET /api/sticky-banners/public
StkC->>StkS: getPublicBannerData()
StkS->>DB: getSettings (findOne where empty, auto-create if none)
DB-->>StkS: settings row
alt activeBannerId equals SYSTEM_DEFAULT_50
StkS->>StkS: Build virtual 50 percent off banner in memory
else activeBannerId equals SYSTEM_DEFAULT_FLAGSHIP
StkS->>StkS: Build virtual flagship banner in memory
else activeBannerId is a real UUID
StkS->>DB: findOne sticky_banners by id
DB-->>StkS: banner or null
end
opt no banner resolved yet
StkS->>DB: findOne where isDefault true
DB-->>StkS: default banner or null
opt still nothing
StkS->>StkS: Use absolute fallback 50 percent off banner
end
end
StkS-->>StkC: banners array plus settings
StkC-->>Web: 200 with banner and settings
Web->>Web: Render only if isEnabled and device matches showOnMobile or showOnDesktop
Journey 3 — Visitor registers for the League (with resume upload)¶
A public form posts multipart data including a resume. The service saves the row first, then attempts the S3 upload and patches the row with the resume key/URL. A failed upload is swallowed so the registration still succeeds. Admins later fetch a short-lived presigned URL to read the resume.
sequenceDiagram
autonumber
actor Visitor
participant FE as Public form
participant Mul as multer (5MB, pdf/doc/docx)
participant LC as LeagueRegistrationController
participant LS as LeagueRegistrationService
participant S3 as S3Service
participant DB as PostgreSQL
Visitor->>FE: Fill details and attach resume
FE->>Mul: POST /api/league-registration (multipart resume)
Mul->>LC: file in memory plus body fields
LC->>LC: Validate required fields, email regex, file present
LC->>LS: createRegistration(data with resume buffer)
LS->>DB: INSERT league_registrations (profiles default empty)
DB-->>LS: saved row with id
alt resume buffer present
LS->>S3: uploadEventResume(buffer, id, name, type)
alt upload ok
S3-->>LS: s3Key and url
LS->>DB: UPDATE row set resumeKey, resumeUrl, resumeFileName
else upload fails
LS->>LS: Catch and log then keep registration
end
end
LS-->>FE: 201 registration created
Note over FE,Visitor: Later — admin reviews
participant Admin as Admin
Admin->>LC: GET /api/admin/league-registrations/:id/resume-url
LC->>LS: getRegistrationById(id)
LS->>DB: findOne by id
LC->>S3: getEventResumeSignedUrl(resumeKey, 3600)
S3-->>LC: presigned URL valid one hour
LC-->>Admin: 200 url and fileName
Journey 4 — Visitor expresses course interest (lead-style capture)¶
The public form captures a lightweight interest record with validation and de-duplication. There is no CRM Lead creation and no email is sent today — both are placeholders. Admin triage happens through the same (currently unguarded) router.
sequenceDiagram
autonumber
actor Visitor
participant FE as Public website
participant CIC as courseInterest.controller
participant Repo as CourseInterest repository
participant DB as PostgreSQL
Visitor->>FE: Submit name, email, phone, interestedIn, category
FE->>CIC: POST /api/interest/
CIC->>CIC: Validate required fields, email regex, phone 10 digits, category enum
CIC->>Repo: findOne by email plus interestedIn plus category
Repo->>DB: SELECT course_interests
alt duplicate exists
CIC-->>FE: 409 already registered interest
else new
CIC->>Repo: create then save (source website, email lowercased, phone digits only)
Repo->>DB: INSERT course_interests (status new)
Note over CIC: Email notification is a commented-out placeholder
Note over CIC: No CRM Lead row is created here
CIC-->>FE: 201 thank you for your interest
end
Note over FE: Later — staff triage
actor Staff
Staff->>CIC: PATCH /api/interest/:id (status contacted, notes, assignedTo)
CIC->>Repo: update fields
opt status equals contacted
CIC->>Repo: set lastContactedAt to now
end
Repo->>DB: UPDATE course_interests
CIC-->>Staff: 200 updated
The AI for Everyone capture (POST /api/ai-for-everyone/interest) follows the exact same shape via AiForEveryoneController → AiForEveryoneService, with a stricter phone regex (^[6-9]\d{9}$) and a college field instead of interestedIn/category.
Background jobs & async¶
There are no scheduled jobs, queues, or timers in this domain. Banner activation/expiry is entirely manual — toggling isActive, flipping isDefault, or pointing activeBannerId at a banner. None of the services use BullMQ, cron, or any time-based activation.
The only asynchronous work is S3 I/O performed inline within the request:
BannerTemplateService.deleteBannerTemplate/S3Service.deleteBannerTemplate— best-effort S3 delete; failures are caught/logged and DB deletion proceeds regardless.LeagueRegistrationService.createRegistration— uploads the resume to S3 after the row is saved; upload failures are swallowed so the registration still returns.
Email is not sent anywhere in this domain. The course-interest controller has an explicit commented-out sendInterestNotificationEmail placeholder.
External integrations¶
AWS S3 is the only external dependency. Two buckets are used (defaults shown; overridable by env):
| Purpose | Env var | Default bucket | Key layout |
|---|---|---|---|
| Banner template artwork (public assets) | AWS_S3_BANNER_ASSETS_BUCKET |
myanalyticsschool-assets |
banner-templates/<category>/<filename> |
| League resume uploads (private documents) | AWS_S3_STUDENT_DOCUMENTS_BUCKET |
myanalytics-documents |
inter-iit-event/<year>/<registrationId>/<filename> |
All S3 access is funnelled through src/services/S3Service.ts (per the repo convention — never raw AWS SDK calls in controllers/services). Standard AWS credentials/region env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) apply.
Upload constraints
- Banner template upload: in-memory
multer, 10 MB limit, allowed MIMEimage/jpeg, image/png, image/jpg, image/gif, image/webp. - League resume upload: in-memory
multer, 5 MB limit, allowed MIME PDF / DOC / DOCX only.
Feature flags / multi-platform: there is no x-platform header handling and no environment feature flag in this domain. The only "targeting" levers are the boolean showOnMobile / showOnDesktop settings flags (interpreted client-side) and the per-banner designTemplate string. (inferred: device targeting logic lives in the consuming frontend, not the backend.)
Status lifecycles¶
There is no time-windowed scheduling. A banner's effective visibility is the combination of its own isActive flag, the settings singleton's master isEnabled, and whether the settings activeBannerId (or isDefault) selects it. The diagram below models a single banner's editorial lifecycle as driven by the admin endpoints.
stateDiagram-v2
[*] --> Draft: POST create with isActive false
[*] --> Active: POST create with isActive true
Draft --> Active: PATCH toggle (isActive true)
Active --> Draft: PATCH toggle (isActive false)
Active --> Selected: PUT settings activeBannerId points here
Selected --> Active: settings pointer moved elsewhere
Active --> DefaultFallback: PUT update isDefault true
DefaultFallback --> Active: another banner set as default
state "Shown to visitors" as Shown
Selected --> Shown: settings isEnabled true and device matches
DefaultFallback --> Shown: no active pointer resolves and isEnabled true
Selected --> Hidden: settings isEnabled false
Active --> Hidden: not selected and not default
Draft --> [*]: DELETE
Active --> [*]: DELETE
Selected --> [*]: DELETE (settings still points at a stale id)
Key points:
- No "scheduled" or "expired" state exists — there are no
startDate/endDatecolumns. (Confirmed across all entities and services.) - A banner can be
isActiveyet never shown, because the public resolver returns a single banner chosen by the settings pointer / default flag, not the full active list. - Deleting a banner does not clear a dangling
activeBannerIdin settings; the public resolver simply falls through to theisDefaultrow or the hard-coded fallback.
Edge cases, limits & gotchas¶
- Inconsistent authorization.
- Sticky/pop-up banner admin routes and event-config admin routes use
authMiddlewareonly — any authenticated user (not just ADMIN) can create/update/delete. - Banner-template and league-registration admin routes correctly add
adminMiddleware. -
Hardening note (internal). A security/hardening observation for this area is tracked in the team's private notes (
internal/security-and-hardening-notes.md) and is intentionally not published on this site. -
Public banner endpoint always returns a banner. Because of the multi-step fallback (virtual default → DB pointer →
isDefault→ absolute hard-codedSYSTEM_DEFAULT_50), the website can never get an empty result — even with zero banners in the DB. Hiding banners must be done viasettings.isEnabled = falseon the client side, not by emptying the table. -
Virtual banners are not in the DB. IDs
SYSTEM_DEFAULT_50,SYSTEM_DEFAULT_FLAGSHIP(sticky) andSYSTEM_DEFAULT_ORIGINAL,SYSTEM_DEFAULT_MAS101,SYSTEM_DEFAULT_MAS_SPECIAL,SYSTEM_DEFAULT_MAS_PROFESSIONAL(pop-up) are matched as magic strings inactiveBannerId. Admin "active list" reads will never include them, but the public endpoint can return them. Hard-coded promo content (Razorpay linkrzp.io/rzp/..., copy) lives in the service code and changes require a deploy. -
displayMode/transitionDurationexist only on sticky settings. The pop-up settings entity is simpler (isEnabled+ mobile/desktop only). The sticky display mode (single | multiple | auto_scroll) andtransitionDurationare interpreted by the frontend; the backend never returns more than the resolved single banner today. -
Default-banner uniqueness is imperative, not a constraint. Setting
isDefault: trueon create/update issuesUPDATE ... SET isDefault = falseacross all rows first. There is no DB-level partial unique index, so a direct DB write or a race could leave multiple defaults; the public resolver would then pick whicheverfindOnereturns. -
reorderBannersuses 0-based indices (displayOrder = index), whilecreateBannerauto-assignsmax + 1. Mixing the two can produce overlappingdisplayOrdervalues; ordering then falls back tocreatedAt DESC. -
League
sourcefilter is silently ignored.LeagueRegistrationControllerreads asourcequery param and passes it togetAllRegistrations/getRegistrationStats/exportAllRegistrations, but the service methods don't accept asourceargument — so the filter is a no-op. (Bug-shaped.) -
League resume upload failures are swallowed. A registration with a failed S3 upload returns 201 with null
resumeKey/resumeUrl; the admin's/resume-urlcall will then fail or return nothing. There is no retry. -
Course interest de-dup is per (email + interestedIn + category). The same person can create many interest rows across different courses/categories; the 409 only blocks an exact triple repeat.
-
No CRM lead linkage. Despite the lead-like
assignedTo/status/lastContactedAtfields, neither course-interest nor AI-for-everyone capture creates a Sales CRMLeador callsLeadEventService. Any pipeline integration would have to be added. (See Related docs.) -
TypeORM auto-sync is ON. Adding a column to any of these entities mutates the live table automatically — be deliberate with type changes (e.g. widening
phonevarchar lengths differ:course_interests.phoneisvarchar(15),league_registrations.phoneisvarchar(20)).
Related docs¶
- Sales CRM & Leads — where interest captures would feed if lead linkage were wired up; shares the
assignedTo/status/lastContactedAttriage semantics. - AI Platform & LLM Gateway — the "AI for Everyone" program these interest forms market.
- Uploads & S3 —
S3Service, bucket layout, presigned URLs, and theAWS_S3_BANNER_ASSETS_BUCKET/AWS_S3_STUDENT_DOCUMENTS_BUCKETconventions used here. - Courses & Enrollment — the cohorts/capsules/accelerators referenced by
CourseInterest.categoryandinterestedIn.