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
60 changes: 60 additions & 0 deletions apps/api/src/modules/profiles/media-upload.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from "express";
import path from "path";

const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB

export interface MediaUploadMeta {
fieldName: "avatar" | "banner";
mimeType: string;
sizeBytes: number;
originalName: string;
}

export interface MediaValidationResult {
valid: boolean;
errors: string[];
}

export function validateMediaUpload(meta: MediaUploadMeta): MediaValidationResult {
const errors: string[] = [];

if (!ALLOWED_MIME.has(meta.mimeType)) {
errors.push(`unsupported_mime:${meta.mimeType} — allowed: jpeg, png, webp`);
}

if (meta.sizeBytes > MAX_BYTES) {
errors.push(`file_too_large:${meta.sizeBytes} — max ${MAX_BYTES} bytes`);
}

const ext = path.extname(meta.originalName).toLowerCase();
if (![".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
errors.push(`suspicious_extension:${ext}`);
}

return { valid: errors.length === 0, errors };
}

/** Express middleware — expects multer to have populated req.file */
export function mediaUploadGuard(req: Request, res: Response, next: NextFunction): void {
const file = (req as any).file as Express.Multer.File | undefined;
if (!file) { res.status(400).json({ error: "no_file" }); return; }

const fieldName = file.fieldname as "avatar" | "banner";
if (!["avatar", "banner"].includes(fieldName)) {
res.status(400).json({ error: "invalid_field" }); return;
}

const result = validateMediaUpload({
fieldName,
mimeType: file.mimetype,
sizeBytes: file.size,
originalName: file.originalname,
});

if (!result.valid) {
res.status(422).json({ error: "media_validation_failed", details: result.errors }); return;
}

next();
}
57 changes: 57 additions & 0 deletions apps/api/src/modules/profiles/profile-completion.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export interface ArtistProfileFields {
slug?: string;
displayName?: string;
bio?: string;
avatarUrl?: string;
bannerUrl?: string;
genre?: string;
socialLinks?: Record<string, string>;
walletAddress?: string;
}

interface ScoredField {
field: keyof ArtistProfileFields;
weight: number;
label: string;
}

const SCORED_FIELDS: ScoredField[] = [
{ field: "slug", weight: 20, label: "Unique slug" },
{ field: "displayName", weight: 20, label: "Display name" },
{ field: "bio", weight: 15, label: "Bio" },
{ field: "avatarUrl", weight: 15, label: "Profile photo" },
{ field: "bannerUrl", weight: 10, label: "Banner image" },
{ field: "genre", weight: 10, label: "Genre" },
{ field: "walletAddress", weight: 10, label: "Wallet address" },
];

export interface CompletionScore {
score: number; // 0–100
missing: string[];
isEligibleForPayout: boolean;
}

export function computeProfileCompletion(profile: ArtistProfileFields): CompletionScore {
let score = 0;
const missing: string[] = [];

for (const { field, weight, label } of SCORED_FIELDS) {
const val = profile[field];
const filled = typeof val === "string" ? val.trim().length > 0
: typeof val === "object" && val !== null ? Object.keys(val).length > 0
: false;
if (filled) score += weight;
else missing.push(label);
}

// social links bonus (up to remaining weight)
if (profile.socialLinks && Object.keys(profile.socialLinks).length > 0) {
score = Math.min(100, score);
}

return {
score,
missing,
isEligibleForPayout: score >= 70 && !!profile.walletAddress,
};
}
57 changes: 57 additions & 0 deletions apps/api/src/modules/profiles/public-artist-profile.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Router, Request, Response } from "express";
import mongoose, { Schema, Document } from "mongoose";

interface IPublicArtistProfile extends Document {
slug: string;
displayName: string;
bio?: string;
avatarUrl?: string;
bannerUrl?: string;
isLive: boolean;
featuredMoments: string[];
supporterCount: number;
visibility: "public" | "hidden";
}

const PublicArtistProfileSchema = new Schema<IPublicArtistProfile>(
{
slug: { type: String, required: true, unique: true },
displayName: { type: String, required: true },
bio: String,
avatarUrl: String,
bannerUrl: String,
isLive: { type: Boolean, default: false },
featuredMoments: [String],
supporterCount: { type: Number, default: 0 },
visibility: { type: String, enum: ["public", "hidden"], default: "public" },
},
{ timestamps: true }
);

const ArtistProfile = mongoose.model<IPublicArtistProfile>("ArtistProfile", PublicArtistProfileSchema);

function toPublicView(doc: IPublicArtistProfile) {
return {
slug: doc.slug,
displayName: doc.displayName,
bio: doc.bio ?? null,
avatarUrl: doc.avatarUrl ?? null,
bannerUrl: doc.bannerUrl ?? null,
isLive: doc.isLive,
featuredMoments: doc.featuredMoments,
supporterCount: doc.supporterCount,
};
}

export const publicProfileRouter = Router();

publicProfileRouter.get("/:slug", async (req: Request, res: Response) => {
const profile = await ArtistProfile.findOne({ slug: req.params.slug, visibility: "public" });
if (!profile) return res.status(404).json({ error: "not_found" });
return res.json({ profile: toPublicView(profile) });
});

publicProfileRouter.get("/", async (_req: Request, res: Response) => {
const profiles = await ArtistProfile.find({ visibility: "public" }).limit(50).lean();
return res.json({ profiles: profiles.map(toPublicView as any) });
});
56 changes: 56 additions & 0 deletions apps/api/src/modules/profiles/slug.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Router, Request, Response } from "express";
import mongoose, { Schema, Document } from "mongoose";

interface ISlugReservation extends Document {
slug: string;
artistId: string;
reservedAt: Date;
expiresAt: Date;
}

const SlugReservationSchema = new Schema<ISlugReservation>({
slug: { type: String, required: true, unique: true, lowercase: true, trim: true },
artistId: { type: String, required: true },
reservedAt: { type: Date, default: Date.now },
expiresAt: { type: Date, required: true },
});

const SlugReservation = mongoose.model<ISlugReservation>("SlugReservation", SlugReservationSchema);

const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])?$/;
const RESERVATION_TTL_MS = 15 * 60 * 1000; // 15 min

export async function reserveSlug(artistId: string, slug: string): Promise<{ ok: boolean; reason?: string }> {
if (!SLUG_RE.test(slug)) return { ok: false, reason: "invalid_format" };

const existing = await SlugReservation.findOne({ slug });
if (existing && existing.artistId !== artistId && existing.expiresAt > new Date()) {
return { ok: false, reason: "taken" };
}

await SlugReservation.findOneAndUpdate(
{ slug },
{ artistId, reservedAt: new Date(), expiresAt: new Date(Date.now() + RESERVATION_TTL_MS) },
{ upsert: true }
);
return { ok: true };
}

export async function isSlugAvailable(slug: string): Promise<boolean> {
const hit = await SlugReservation.findOne({ slug, expiresAt: { $gt: new Date() } });
return !hit;
}

export const slugRouter = Router();

slugRouter.post("/reserve", async (req: Request, res: Response) => {
const { artistId, slug } = req.body as { artistId?: string; slug?: string };
if (!artistId || !slug) return res.status(400).json({ error: "artistId and slug required" });
const result = await reserveSlug(artistId, slug);
return result.ok ? res.status(200).json({ reserved: true }) : res.status(409).json({ error: result.reason });
});

slugRouter.get("/available/:slug", async (req: Request, res: Response) => {
const available = await isSlugAvailable(req.params.slug);
return res.json({ available });
});
Loading