mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// models/advertisements/advertisements.attributes.js
|
||||
|
||||
// ─── Exclude sets ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Fields hidden from all roles (internal/denormalized details)
|
||||
const excludeAttributes = [
|
||||
"image_asset_id", // internal FK, "image" association exposes the resolved asset instead
|
||||
];
|
||||
|
||||
// Admins see everything except the base excludes
|
||||
const adminExclude = [
|
||||
...excludeAttributes,
|
||||
];
|
||||
|
||||
// Regular users also cannot see audit trails, soft-delete info, or click metrics
|
||||
const userExclude = [
|
||||
...excludeAttributes,
|
||||
"click_count",
|
||||
"createdBy",
|
||||
"updatedBy",
|
||||
"deletedBy",
|
||||
"deletedAt",
|
||||
];
|
||||
|
||||
// ─── JSONB schemas ──────────────────────────────────────────────────────────
|
||||
// Describes shape of the `ctas` JSONB column for paginate's coercion/validation step.
|
||||
const jsonbSchemas = {
|
||||
ctas: {
|
||||
type: "array",
|
||||
itemShape: {
|
||||
label: "string",
|
||||
link: "string",
|
||||
variant: "string", // "default" | "outline"
|
||||
},
|
||||
},
|
||||
badge_labels: {
|
||||
type: "array",
|
||||
itemShape: "string",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Computed attributes ──────────────────────────────────────────────────────
|
||||
// Add any SQL-computed fields here (e.g. a "is_currently_live" derived flag).
|
||||
// Format: { key, label, type, order, literal }
|
||||
const computedAttributes = [
|
||||
// Example:
|
||||
// {
|
||||
// key: "is_live",
|
||||
// label: "Currently Live",
|
||||
// type: "boolean",
|
||||
// order: 99,
|
||||
// literal: `("Advertisement"."is_active" = true AND "Advertisement"."start_date" <= NOW() AND ("Advertisement"."end_date" IS NULL OR "Advertisement"."end_date" >= NOW()))`,
|
||||
// },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
excludeAttributes,
|
||||
adminExclude,
|
||||
userExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
// models/advertisements/advertisements.mdl.js
|
||||
const { DataTypes } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const mdl_Users = require("../users/users.mdl");
|
||||
const mdl_Assets = require("../assets/assets.mdl");
|
||||
|
||||
const Advertisement = sequelize.define("Advertisement", {
|
||||
|
||||
// ─── Identity ─────────────────────────────────────────────────────────────
|
||||
advertisement_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Advertisement ID", order: 0, hidden: true },
|
||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
|
||||
|
||||
// ─── Identification / placement ──────────────────────────────────────────
|
||||
// placement is the source of truth for "where" (a registry key, see
|
||||
// advertisements.placements.js); type is denormalized from it on every
|
||||
// write (applyAdvertisementFields) and describes "what it looks like".
|
||||
placement: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
filterable: true,
|
||||
label: "Placement", order: 1
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"),
|
||||
allowNull: false,
|
||||
filterable: true,
|
||||
label: "Format", order: 2
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"),
|
||||
allowNull: false,
|
||||
defaultValue: "draft", label: "Status", order: 3
|
||||
},
|
||||
|
||||
// ─── Content ──────────────────────────────────────────────────────────────
|
||||
// Every ad is now created/edited as "content" (badge/headline/description/
|
||||
// image/link all mandatory) — applyAdvertisementFields on the backend fixes
|
||||
// this server-side rather than accepting it from the client. "image" is
|
||||
// legacy-only, left on rows created before the Image Only / Text with Image
|
||||
// toggle was removed, until they're next edited.
|
||||
content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 },
|
||||
// Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS.
|
||||
badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 },
|
||||
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
|
||||
description: { type: DataTypes.TEXT, label: "Description", order: 6 },
|
||||
|
||||
// ─── Media ────────────────────────────────────────────────────────────────
|
||||
image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true },
|
||||
image_asset_id: { type: DataTypes.BIGINT, allowNull: true, label: "Image Asset", order: 0, hidden: true },
|
||||
|
||||
// ─── Calls-to-action ──────────────────────────────────────────────────────
|
||||
// [{ label, link }, ...] — 0-2 entries depending on type
|
||||
ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 },
|
||||
|
||||
// Where the ad as a whole links to when clicked with no CTA of its own
|
||||
// (banners/full-image ads have no CTA row) — takes priority over landing_page.
|
||||
redirect_link: { type: DataTypes.STRING(512), allowNull: true, label: "Redirect Link", order: 7.5 },
|
||||
|
||||
// Internally-authored landing page { title, description, body, links: [{label, link}] }
|
||||
// used as the click-through destination when redirect_link is empty — see
|
||||
// GET /api/client/advertisements/uuid/:uuid and the /ads/:uuid client route.
|
||||
landing_page: { type: DataTypes.JSONB, allowNull: true, label: "Landing Page", order: 0, hidden: true },
|
||||
|
||||
// ─── Scheduling ───────────────────────────────────────────────────────────
|
||||
start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 },
|
||||
end_date: { type: DataTypes.DATE, label: "End Date", order: 9 },
|
||||
|
||||
// ─── Display behavior ─────────────────────────────────────────────────────
|
||||
order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 10 },
|
||||
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 11 },
|
||||
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 12 }, // banner-only, ignored by other types
|
||||
|
||||
// ─── Metrics ──────────────────────────────────────────────────────────────
|
||||
click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 13, hidden: true },
|
||||
|
||||
// ─── Audit trails ─────────────────────────────────────────────────────────
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: "advertisements",
|
||||
timestamps: true, // createdAt, updatedAt
|
||||
paranoid: true,
|
||||
indexes: [
|
||||
{ fields: ["uuid"] },
|
||||
{ fields: ["type"] },
|
||||
{ fields: ["placement"] },
|
||||
{ fields: ["status"] },
|
||||
{ fields: ["is_active"] },
|
||||
{ fields: ["deletedAt"] },
|
||||
],
|
||||
});
|
||||
|
||||
Advertisement.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
|
||||
Advertisement.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||
Advertisement.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||
|
||||
module.exports = Advertisement;
|
||||
@@ -0,0 +1,28 @@
|
||||
// models/advertisements/advertisements.placements.js
|
||||
//
|
||||
// Declarative registry of every ad placement in the client app. Each entry
|
||||
// is a self-contained "slot" — a page + position pair — that determines the
|
||||
// visual format (hero/banner/popup/sidebar) automatically. Adding a new
|
||||
// placement should only ever require adding one entry here (and wiring the
|
||||
// corresponding client page to fetch/render it) — nothing else in this file
|
||||
// should need to change.
|
||||
//
|
||||
// `key` is what's stored on advertisements.placement. `format` is what gets
|
||||
// denormalized onto advertisements.type on write (see applyAdvertisementFields
|
||||
// in controllers/admin/advertisements.controller.js) — type is never accepted
|
||||
// from the client once a placement is set.
|
||||
|
||||
const PLACEMENTS = [
|
||||
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
||||
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Subscriptions", slotLabel: "Banner (above plan cards)" },
|
||||
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
|
||||
];
|
||||
|
||||
const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
|
||||
const PLACEMENT_KEYS = PLACEMENTS.map((p) => p.key);
|
||||
|
||||
function getFormatForPlacement(key) {
|
||||
return PLACEMENT_MAP[key]?.format ?? null;
|
||||
}
|
||||
|
||||
module.exports = { PLACEMENTS, PLACEMENT_MAP, PLACEMENT_KEYS, getFormatForPlacement };
|
||||
Reference in New Issue
Block a user