From 072d983e96cb95de90805efa3d3ca8c0214046e8 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:35:55 -0500 Subject: [PATCH 01/11] feat(db): add reciprocity credit columns, drop clout/pacing columns groups gains reciprocity_seed/cap/ratio and drops daily_share_limit, share_pacing_mode, share_burst, share_cooldown_minutes, clout_enabled. users gains post_credits and drops the clout_* columns. clip_queue drops position/scheduled_at (the queue is now an unscheduled holding area). --- .../db/migrations/0033_reciprocity_pacing.sql | 14 ++++++++++++++ src/lib/server/db/migrations/meta/_journal.json | 7 +++++++ src/lib/server/db/schema.ts | 14 ++++---------- 3 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 src/lib/server/db/migrations/0033_reciprocity_pacing.sql diff --git a/src/lib/server/db/migrations/0033_reciprocity_pacing.sql b/src/lib/server/db/migrations/0033_reciprocity_pacing.sql new file mode 100644 index 0000000..fe45648 --- /dev/null +++ b/src/lib/server/db/migrations/0033_reciprocity_pacing.sql @@ -0,0 +1,14 @@ +ALTER TABLE `groups` ADD `reciprocity_seed` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` ADD `reciprocity_cap` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` ADD `reciprocity_ratio` integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `post_credits` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `daily_share_limit`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `share_pacing_mode`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `share_burst`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `share_cooldown_minutes`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `clout_enabled`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `clout_tier`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `clout_change_shown_at`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `clout_tier_changed_at`;--> statement-breakpoint +ALTER TABLE `clip_queue` DROP COLUMN `position`;--> statement-breakpoint +ALTER TABLE `clip_queue` DROP COLUMN `scheduled_at`; diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json index f5882c9..62eb516 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1773457298216, "tag": "0032_brown_impossible_man", "breakpoints": true + }, + { + "idx": 33, + "version": "6", + "when": 1780072362388, + "tag": "0033_reciprocity_pacing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 8074717..b5b2e2e 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -11,11 +11,9 @@ export const groups = sqliteTable('groups', { downloadProvider: text('download_provider'), platformFilterMode: text('platform_filter_mode').notNull().default('all'), platformFilterList: text('platform_filter_list'), - dailyShareLimit: integer('daily_share_limit'), - sharePacingMode: text('share_pacing_mode').notNull().default('off'), - shareBurst: integer('share_burst').notNull().default(2), - shareCooldownMinutes: integer('share_cooldown_minutes').notNull().default(120), - cloutEnabled: integer('clout_enabled', { mode: 'boolean' }).notNull().default(true), + reciprocitySeed: integer('reciprocity_seed').notNull().default(5), + reciprocityCap: integer('reciprocity_cap').notNull().default(5), + reciprocityRatio: integer('reciprocity_ratio').notNull().default(1), shortcutToken: text('shortcut_token').unique(), shortcutUrl: text('shortcut_url'), createdBy: text('created_by'), @@ -36,9 +34,7 @@ export const users = sqliteTable('users', { avatarPath: text('avatar_path'), lastLegacyShareAt: integer('last_legacy_share_at', { mode: 'timestamp' }), usedNewShareFlow: integer('used_new_share_flow', { mode: 'boolean' }).notNull().default(false), - cloutTier: text('clout_tier'), - cloutChangeShownAt: integer('clout_change_shown_at', { mode: 'timestamp' }), - cloutTierChangedAt: integer('clout_tier_changed_at', { mode: 'timestamp' }), + postCredits: integer('post_credits').notNull().default(5), removedAt: integer('removed_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }); @@ -234,8 +230,6 @@ export const clipQueue = sqliteTable( groupId: text('group_id') .notNull() .references(() => groups.id), - position: integer('position').notNull(), - scheduledAt: integer('scheduled_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }, (table) => [index('clip_queue_user_group').on(table.userId, table.groupId)] From 119212afe4566f834174ddd8a3fea9cd55b8c456 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:36:59 -0500 Subject: [PATCH 02/11] feat(server): add reciprocity credit module and seed new users New credits.ts owns the atomic earn/spend primitives, group config lookup, and ratio-gated watch crediting. New members are seeded with the group's configured starting credits at all four user-creation sites. --- src/lib/server/credits.ts | 126 ++++++++++++++++++++++++ src/routes/api/auth/+server.ts | 3 + src/routes/api/group/members/+server.ts | 2 + src/routes/join/[code]/+page.server.ts | 2 + 4 files changed, 133 insertions(+) create mode 100644 src/lib/server/credits.ts diff --git a/src/lib/server/credits.ts b/src/lib/server/credits.ts new file mode 100644 index 0000000..7199f93 --- /dev/null +++ b/src/lib/server/credits.ts @@ -0,0 +1,126 @@ +import { db } from '$lib/server/db'; +import { users, groups, watched, clips } from '$lib/server/db/schema'; +import { eq, and, ne, sql } from 'drizzle-orm'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('credits'); + +export interface ReciprocityConfig { + /** Credits a brand-new member starts with. */ + seed: number; + /** Maximum credits a member can hold at once. */ + cap: number; + /** How many qualifying watches earn one credit (≥1). */ + ratio: number; +} + +const FALLBACK: ReciprocityConfig = { seed: 5, cap: 5, ratio: 1 }; + +/** + * Read a group's reciprocity tuning. Falls back to 5/5/1 if the group is missing. + */ +export function getReciprocityConfig(groupId: string): ReciprocityConfig { + const [group] = db + .select({ + seed: groups.reciprocitySeed, + cap: groups.reciprocityCap, + ratio: groups.reciprocityRatio + }) + .from(groups) + .where(eq(groups.id, groupId)) + .all(); + + if (!group) return { ...FALLBACK }; + return { + seed: group.seed, + cap: group.cap, + ratio: Math.max(1, group.ratio) + }; +} + +/** + * Current post-credit balance for a user (0 if user is missing). + */ +export function getCredits(userId: string): number { + const [row] = db + .select({ postCredits: users.postCredits }) + .from(users) + .where(eq(users.id, userId)) + .all(); + return row?.postCredits ?? 0; +} + +/** + * Atomically spend one credit. The `post_credits >= 1` guard in the WHERE clause + * is the concurrency-safety mechanism: two racing spenders at balance 1 can never + * both succeed — exactly one UPDATE reports a changed row. + * + * Returns true if a credit was spent, false if the balance was already 0. + */ +export function spendCredit(userId: string): boolean { + const result = db + .update(users) + .set({ postCredits: sql`${users.postCredits} - 1` }) + .where(and(eq(users.id, userId), sql`${users.postCredits} >= 1`)) + .run(); + return result.changes === 1; +} + +/** + * Grant one credit, clamped to the group's cap. Returns the new balance. + */ +export function earnCredit(userId: string, groupId: string): number { + const { cap } = getReciprocityConfig(groupId); + db.update(users) + .set({ postCredits: sql`MIN(${users.postCredits} + 1, ${cap})` }) + .where(eq(users.id, userId)) + .run(); + return getCredits(userId); +} + +/** + * Refund one credit (e.g. a publish failed after the credit was spent), clamped to cap. + * Returns the new balance. + */ +export function refundCredit(userId: string, groupId: string): number { + return earnCredit(userId, groupId); +} + +/** + * Decide whether a freshly-recorded watch earns a credit, applying the group's ratio. + * + * Only call this for a NEW (first-time) watch of ANOTHER member's clip — the caller + * is responsible for excluding self-watches and repeat watches. + * + * With ratio 1 every qualifying watch earns. With ratio N, a credit is granted on + * every Nth qualifying watch, counted over the user's lifetime non-self watches + * (the just-inserted watch is included in the count). + * + * Returns the user's balance after any grant. + */ +export function grantWatchCredit(userId: string, groupId: string): number { + const { ratio } = getReciprocityConfig(groupId); + + if (ratio <= 1) { + const credits = earnCredit(userId, groupId); + log.info({ userId, groupId, credits }, 'watch earned credit'); + return credits; + } + + // Count this user's lifetime qualifying (non-self) watches, then earn on every Nth. + const [row] = db + .select({ count: sql`count(*)` }) + .from(watched) + .innerJoin(clips, eq(watched.clipId, clips.id)) + .where(and(eq(watched.userId, userId), ne(clips.addedBy, userId))) + .all(); + + const qualifyingWatches = row?.count ?? 0; + if (qualifyingWatches > 0 && qualifyingWatches % ratio === 0) { + const credits = earnCredit(userId, groupId); + log.info({ userId, groupId, qualifyingWatches, ratio, credits }, 'watch earned credit'); + return credits; + } + + return getCredits(userId); +} diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index a3665a4..c51aa9d 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -11,6 +11,7 @@ import { users, groups, notificationPreferences, verificationCodes } from '$lib/ import { v4 as uuid } from 'uuid'; import { eq, and, desc, ne, isNull } from 'drizzle-orm'; import { sendVerification, checkVerification } from '$lib/server/sms/verify'; +import { getReciprocityConfig } from '$lib/server/credits'; import { dev } from '$app/environment'; import { createLogger } from '$lib/server/logger'; import { checkRateLimit, rateLimitResponse } from '$lib/server/rate-limit'; @@ -33,6 +34,7 @@ async function handleJoin(body: Record) { username: '', phone: placeholderPhone, groupId: group.id, + postCredits: getReciprocityConfig(group.id).seed, createdAt: new Date() }); @@ -202,6 +204,7 @@ async function ensureDevUser(phone: string, existingUser: typeof users.$inferSel username: 'dev', phone, groupId: group!.id, + postCredits: getReciprocityConfig(group!.id).seed, createdAt: new Date() }); await db.insert(notificationPreferences).values({ userId }); diff --git a/src/routes/api/group/members/+server.ts b/src/routes/api/group/members/+server.ts index 8cd0843..0ebb684 100644 --- a/src/routes/api/group/members/+server.ts +++ b/src/routes/api/group/members/+server.ts @@ -6,6 +6,7 @@ import { eq, isNull, and } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { normalizePhone } from '$lib/server/phone'; import { withAuth, withHost, parseBody, isResponse } from '$lib/server/api-utils'; +import { getReciprocityConfig } from '$lib/server/credits'; export const GET: RequestHandler = withAuth(async (_event, { group }) => { const members = await db.query.users.findMany({ @@ -70,6 +71,7 @@ export const POST: RequestHandler = withHost(async ({ request }, { group }) => { username, phone, groupId: group.id, + postCredits: getReciprocityConfig(group.id).seed, createdAt: now }) .run(); diff --git a/src/routes/join/[code]/+page.server.ts b/src/routes/join/[code]/+page.server.ts index 16ee474..7b00287 100644 --- a/src/routes/join/[code]/+page.server.ts +++ b/src/routes/join/[code]/+page.server.ts @@ -5,6 +5,7 @@ import { db } from '$lib/server/db'; import { users, notificationPreferences } from '$lib/server/db/schema'; import { v4 as uuid } from 'uuid'; import { checkRateLimit } from '$lib/server/rate-limit'; +import { getReciprocityConfig } from '$lib/server/credits'; export const load: PageServerLoad = async ({ params }) => { const group = await validateInviteCode(params.code); @@ -40,6 +41,7 @@ export const actions: Actions = { username: '', phone: placeholderPhone, groupId: group.id, + postCredits: getReciprocityConfig(group.id).seed, createdAt: new Date() }); From 80d0c96a1eb558203a9747e87bc30f12ad89cb91 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:39:02 -0500 Subject: [PATCH 03/11] feat(queue): unscheduled holding queue with credit-gated posting Rewrites queue.ts into an unlimited holding area: enqueue, publishQueuedClip (now async + notifies), postQueuedClips (multi-select, spends a credit each), and setClipReady (direct-post spends at publish, degrades to queue if drained). Adds POST /api/queue/post, paginates GET /api/queue, and removes the reorder + move-to-top routes. Deletes the 30s auto-publish scheduler timer so nothing leaves the queue except a deliberate credit spend. Failed downloads clean up their orphan queue row. --- src/lib/server/clip-download.ts | 3 + src/lib/server/queue.ts | 375 +++++------------- src/lib/server/scheduler.ts | 19 - src/routes/api/queue/+server.ts | 20 +- .../api/queue/[id]/move-to-top/+server.ts | 10 - src/routes/api/queue/post/+server.ts | 28 ++ src/routes/api/queue/reorder/+server.ts | 19 - 7 files changed, 136 insertions(+), 338 deletions(-) delete mode 100644 src/routes/api/queue/[id]/move-to-top/+server.ts create mode 100644 src/routes/api/queue/post/+server.ts delete mode 100644 src/routes/api/queue/reorder/+server.ts diff --git a/src/lib/server/clip-download.ts b/src/lib/server/clip-download.ts index 3c64bbd..d3a649c 100644 --- a/src/lib/server/clip-download.ts +++ b/src/lib/server/clip-download.ts @@ -3,6 +3,7 @@ import { clips } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { downloadVideo } from '$lib/server/video/download'; import { downloadMusic } from '$lib/server/music/download'; +import { removeQueueEntryByClip } from '$lib/server/queue'; import { createLogger } from '$lib/server/logger'; const log = createLogger('clip-download'); @@ -30,6 +31,8 @@ export async function startDownload( .update(clips) .set({ status: 'failed' }) .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + // Drop any holding-queue row so a failed clip doesn't linger in the user's queue. + removeQueueEntryByClip(clipId); }; if (contentType === 'music') { diff --git a/src/lib/server/queue.ts b/src/lib/server/queue.ts index 4779c6f..ccd06b5 100644 --- a/src/lib/server/queue.ts +++ b/src/lib/server/queue.ts @@ -1,150 +1,33 @@ import { db } from '$lib/server/db'; -import { clips, clipQueue, groups } from '$lib/server/db/schema'; -import { eq, and, sql, desc, lte, gte } from 'drizzle-orm'; +import { clips, clipQueue } from '$lib/server/db/schema'; +import { eq, and, sql, asc, desc } from 'drizzle-orm'; import { notifyNewClip } from '$lib/server/push'; +import { spendCredit, earnCredit } from '$lib/server/credits'; import { createLogger } from '$lib/server/logger'; import { v4 as uuid } from 'uuid'; const log = createLogger('queue'); -const DEFAULT_QUEUE_DEPTH = 10; - -/** - * Check if a user still has burst slots available (instant shares). - * Uses a rolling window: burst × cooldownMinutes. - */ -export function checkBurstAvailable( - userId: string, - groupId: string, - burst: number, - cooldownMinutes: number -): { available: boolean; burstUsed: number; nextSlotAt: Date | null } { - const windowMs = burst * cooldownMinutes * 60 * 1000; - const windowStart = new Date(Date.now() - windowMs); - - const notInQueue = sql`${clips.id} NOT IN (SELECT clip_id FROM clip_queue WHERE user_id = ${userId} AND group_id = ${groupId})`; - - // Count clips this user created in the rolling window that went instant - // (i.e. clips NOT in the clip_queue table) - const [result] = db - .select({ count: sql`count(*)` }) - .from(clips) - .where( - and( - eq(clips.addedBy, userId), - eq(clips.groupId, groupId), - gte(clips.createdAt, windowStart), - notInQueue - ) - ) - .all(); - - const burstUsed = result?.count ?? 0; - const available = burstUsed < burst; - - let nextSlotAt: Date | null = null; - if (!available) { - // Find the oldest instant clip in the window — when it ages out, a slot opens - const [oldest] = db - .select({ createdAt: clips.createdAt }) - .from(clips) - .where( - and( - eq(clips.addedBy, userId), - eq(clips.groupId, groupId), - gte(clips.createdAt, windowStart), - notInQueue - ) - ) - .orderBy(clips.createdAt) - .limit(1) - .all(); - - if (oldest?.createdAt) { - nextSlotAt = new Date(oldest.createdAt.getTime() + windowMs); - } - } - - return { available, burstUsed, nextSlotAt }; -} - /** - * Add a clip to the queue. Returns the queue entry with scheduled publish time. + * Add a clip to a user's personal holding queue. The queue is unlimited — clips wait + * here (status 'queued') until the user spends a credit to post them to the feed. + * Returns the new queue entry id. */ -export function enqueueClip( - clipId: string, - userId: string, - groupId: string, - cooldownMinutes: number, - burst = 1, - queueLimit: number | null = DEFAULT_QUEUE_DEPTH -): { id: string; scheduledAt: Date; position: number } | null { - // Check queue depth - const [countResult] = db - .select({ count: sql`count(*)` }) - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .all(); - - const currentCount = countResult?.count ?? 0; - const effectiveLimit = queueLimit ?? Infinity; - if (currentCount >= effectiveLimit) { - return null; // Queue full - } - - // Find the latest scheduled_at for this user to chain from - const [latest] = db - .select({ scheduledAt: clipQueue.scheduledAt, position: clipQueue.position }) - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(desc(clipQueue.scheduledAt)) - .limit(1) - .all(); - - const now = new Date(); - const cooldownMs = cooldownMinutes * 60 * 1000; - const position = (latest?.position ?? -1) + 1; - - // Burst grouping: clips at the same burst boundary share a scheduled time. - // Position 0..burst-1 → first window, burst..2*burst-1 → second window, etc. - const isNewWindow = position % Math.max(1, burst) === 0; - let scheduledAt: Date; - if (isNewWindow || !latest?.scheduledAt) { - const baseTime = - latest?.scheduledAt && latest.scheduledAt.getTime() > now.getTime() - ? latest.scheduledAt - : now; - scheduledAt = new Date(baseTime.getTime() + cooldownMs); - } else { - // Same burst window — use the same time as the previous clip - scheduledAt = latest.scheduledAt.getTime() > now.getTime() ? latest.scheduledAt : now; - } - +export function enqueueClip(clipId: string, userId: string, groupId: string): string { const id = uuid(); - - db.insert(clipQueue) - .values({ - id, - clipId, - userId, - groupId, - position, - scheduledAt, - createdAt: now - }) - .run(); - - log.info({ clipId, userId, scheduledAt: scheduledAt.toISOString(), position }, 'clip enqueued'); - return { id, scheduledAt, position }; + db.insert(clipQueue).values({ id, clipId, userId, groupId, createdAt: new Date() }).run(); + log.info({ clipId, userId }, 'clip enqueued'); + return id; } /** - * Publish a queued clip: set status to ready, update createdAt. - * No push notification — queued clips appear silently in the feed. + * Publish a queued clip to the feed: flip status queued→ready, drop the queue row, and + * notify the rest of the group (a manual post is a real new clip for everyone else). + * Credit-spending is the caller's responsibility (see /api/queue/post). + * Returns true on success, false if the entry is missing or its clip isn't 'queued'. */ -export function publishQueuedClip(queueEntryId: string): boolean { +export async function publishQueuedClip(queueEntryId: string): Promise { const [entry] = db.select().from(clipQueue).where(eq(clipQueue.id, queueEntryId)).all(); - if (!entry) return false; const [clip] = db @@ -152,25 +35,29 @@ export function publishQueuedClip(queueEntryId: string): boolean { .from(clips) .where(eq(clips.id, entry.clipId)) .all(); - if (!clip || clip.status !== 'queued') return false; const now = new Date(); - - // Update clip: mark ready and set createdAt to now so it appears at the right feed position + // Set createdAt to now so the freshly-posted clip lands at the right feed position. db.update(clips).set({ status: 'ready', createdAt: now }).where(eq(clips.id, entry.clipId)).run(); - - // Remove from queue db.delete(clipQueue).where(eq(clipQueue.id, queueEntryId)).run(); + await notifyNewClip(entry.clipId).catch((err) => + log.error({ err, clipId: entry.clipId }, 'push notification failed') + ); + log.info({ clipId: entry.clipId, queueEntryId }, 'queued clip published'); return true; } /** * Called from the download pipeline when a clip finishes downloading. - * If the clip is in the queue, sets status to 'queued' instead of 'ready'. - * If not queued, sets status to 'ready' and fires notifications normally. + * + * - If the clip has a queue row (it was a deliberate "add to queue"), it stays held as + * 'queued' and costs nothing — the user posts it later by spending a credit. + * - Otherwise it's a direct post: spend one of the uploader's credits and go straight to + * 'ready' (notifying the group). If the uploader's credits were drained by a race since + * share time, gracefully fall back to holding it in the queue rather than posting free. * * Returns the final status that was set. */ @@ -185,7 +72,7 @@ export async function setClipReady( .all(); if (queueEntry) { - // Clip is queued — set status to 'queued', scheduler will publish later + // Deliberately queued — hold until the user posts it. db.update(clips) .set({ ...updates, status: 'queued' }) .where(eq(clips.id, clipId)) @@ -194,7 +81,24 @@ export async function setClipReady( return 'queued'; } - // Not queued — normal flow + // Direct post — needs a credit from the uploader. + const [clip] = db + .select({ addedBy: clips.addedBy, groupId: clips.groupId }) + .from(clips) + .where(eq(clips.id, clipId)) + .all(); + + if (clip && !spendCredit(clip.addedBy)) { + // No credit available anymore — degrade to a held queue entry instead of posting free. + enqueueClip(clipId, clip.addedBy, clip.groupId); + db.update(clips) + .set({ ...updates, status: 'queued' }) + .where(eq(clips.id, clipId)) + .run(); + log.info({ clipId }, 'direct post had no credit, held in queue'); + return 'queued'; + } + db.update(clips) .set({ ...updates, status: 'ready' }) .where(eq(clips.id, clipId)) @@ -208,15 +112,23 @@ export async function setClipReady( } /** - * Get a user's queued clips with details. + * Get a user's queued clips with details, newest-first by default. `order` flips it. + * Supports lazy-load pagination via limit/offset. */ -export function getUserQueue(userId: string, groupId: string) { - return db +export function getUserQueue( + userId: string, + groupId: string, + opts: { limit?: number; offset?: number; order?: 'newest' | 'oldest' } = {} +) { + const { limit, offset = 0, order = 'newest' } = opts; + const dir = order === 'oldest' ? asc : desc; + // rowid (insertion order) is a stable tiebreak when two rows share a createdAt millisecond. + const orderBy = [dir(clipQueue.createdAt), dir(sql`clip_queue.rowid`)]; + + let q = db .select({ id: clipQueue.id, clipId: clipQueue.clipId, - position: clipQueue.position, - scheduledAt: clipQueue.scheduledAt, createdAt: clipQueue.createdAt, title: clips.title, originalUrl: clips.originalUrl, @@ -228,12 +140,15 @@ export function getUserQueue(userId: string, groupId: string) { .from(clipQueue) .innerJoin(clips, eq(clipQueue.clipId, clips.id)) .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(clipQueue.position) - .all(); + .orderBy(...orderBy) + .$dynamic(); + + if (limit !== undefined) q = q.limit(limit).offset(offset); + return q.all(); } /** - * Cancel a single queued clip. Removes from queue and deletes the clip. + * Cancel a single queued clip. Removes from queue and soft-deletes the clip. */ export function cancelQueuedClip(queueEntryId: string, userId: string): boolean { const [entry] = db @@ -252,99 +167,15 @@ export function cancelQueuedClip(queueEntryId: string, userId: string): boolean } /** - * Move a queue entry to the top (position 0). Recalculate scheduled times. - */ -export function moveToTop(queueEntryId: string, userId: string, groupId: string): boolean { - const entries = db - .select() - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(clipQueue.position) - .all(); - - const targetIdx = entries.findIndex((e) => e.id === queueEntryId); - if (targetIdx < 0) return false; - - // Move target to front - const [target] = entries.splice(targetIdx, 1); - entries.unshift(target); - - const [group] = db - .select({ shareCooldownMinutes: groups.shareCooldownMinutes, shareBurst: groups.shareBurst }) - .from(groups) - .where(eq(groups.id, groupId)) - .all(); - if (!group) return false; - - recalculateScheduledTimes(entries, group.shareCooldownMinutes, group.shareBurst); - return true; -} - -/** - * Reorder the queue by an ordered list of queue entry IDs. - */ -export function reorderQueue(userId: string, groupId: string, orderedIds: string[]): boolean { - const entries = db - .select() - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(clipQueue.position) - .all(); - - // Validate all IDs match - if (orderedIds.length !== entries.length) return false; - const entryMap = new Map(entries.map((e) => [e.id, e])); - const reordered = orderedIds.map((id) => entryMap.get(id)).filter(Boolean) as typeof entries; - if (reordered.length !== entries.length) return false; - - const [group] = db - .select({ shareCooldownMinutes: groups.shareCooldownMinutes, shareBurst: groups.shareBurst }) - .from(groups) - .where(eq(groups.id, groupId)) - .all(); - if (!group) return false; - - recalculateScheduledTimes(reordered, group.shareCooldownMinutes, group.shareBurst); - return true; -} - -/** - * Recalculate positions and scheduled_at for a reordered list of queue entries. + * Remove a queue row for a clip without touching the clip (used to clean up after a + * failed download so the orphan entry doesn't linger). */ -function recalculateScheduledTimes( - entries: { id: string; scheduledAt: Date }[], - cooldownMinutes: number, - burst = 1 -) { - const cooldownMs = cooldownMinutes * 60 * 1000; - const now = new Date(); - const safeBurst = Math.max(1, burst); - - for (let i = 0; i < entries.length; i++) { - const isNewWindow = i % safeBurst === 0; - let scheduledAt: Date; - - if (i === 0) { - scheduledAt = new Date(now.getTime() + cooldownMs); - } else if (isNewWindow) { - const baseTime = entries[i - 1].scheduledAt; - scheduledAt = new Date(Math.max(baseTime.getTime(), now.getTime()) + cooldownMs); - } else { - // Same burst window — share the previous entry's time - scheduledAt = entries[i - 1].scheduledAt; - } - - entries[i].scheduledAt = scheduledAt; - - db.update(clipQueue) - .set({ position: i, scheduledAt }) - .where(eq(clipQueue.id, entries[i].id)) - .run(); - } +export function removeQueueEntryByClip(clipId: string): void { + db.delete(clipQueue).where(eq(clipQueue.clipId, clipId)).run(); } /** - * Clear the entire queue for a user. Deletes all queued clips. + * Clear the entire queue for a user. Soft-deletes all queued clips. */ export function clearQueue(userId: string, groupId: string): number { const entries = db @@ -368,39 +199,41 @@ export function clearQueue(userId: string, groupId: string): number { } /** - * Flush all queued clips for a group (publish immediately). - * Used when switching away from queue pacing mode. + * Post selected queued clips to the feed, spending one credit each. The loop self-limits + * to the user's available credits via the atomic spend guard. Returns the clip IDs posted. + * + * `entryIds` are clip_queue entry IDs; only the caller's own 'queued' entries are posted. */ -export function flushQueue(groupId: string): number { - const entries = db - .select({ - id: clipQueue.id, - clipId: clipQueue.clipId - }) +export async function postQueuedClips( + userId: string, + groupId: string, + entryIds: string[] +): Promise { + const rows = db + .select({ id: clipQueue.id, clipId: clipQueue.clipId, status: clips.status }) .from(clipQueue) .innerJoin(clips, eq(clipQueue.clipId, clips.id)) - .where(and(eq(clipQueue.groupId, groupId), eq(clips.status, 'queued'))) + .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) .all(); - if (entries.length === 0) { - // Still clean up any queue entries for non-queued clips - db.delete(clipQueue).where(eq(clipQueue.groupId, groupId)).run(); - return 0; - } + const byId = new Map(rows.map((r) => [r.id, r])); + const posted: string[] = []; - const now = new Date(); + for (const entryId of entryIds) { + const row = byId.get(entryId); + if (!row || row.status !== 'queued') continue; // ownership + state guard + if (!spendCredit(userId)) break; // out of credits — stop - for (const entry of entries) { - db.update(clips) - .set({ status: 'ready', createdAt: now }) - .where(eq(clips.id, entry.clipId)) - .run(); + const ok = await publishQueuedClip(entryId); + if (ok) { + posted.push(row.clipId); + } else { + // Publish failed after a successful spend — refund to stay consistent. + earnCredit(userId, groupId); + } } - db.delete(clipQueue).where(eq(clipQueue.groupId, groupId)).run(); - - log.info({ groupId, published: entries.length }, 'queue flushed'); - return entries.length; + return posted; } /** @@ -415,21 +248,3 @@ export function getQueueCount(userId: string, groupId: string): number { return result?.count ?? 0; } - -/** - * Get queue entries that are due to be published. - */ -export function getDueQueueEntries() { - const now = new Date(); - return db - .select({ - id: clipQueue.id, - clipId: clipQueue.clipId, - userId: clipQueue.userId, - groupId: clipQueue.groupId, - scheduledAt: clipQueue.scheduledAt - }) - .from(clipQueue) - .where(lte(clipQueue.scheduledAt, now)) - .all(); -} diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index cd60777..64eaccc 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -11,7 +11,6 @@ import { sendNotification } from '$lib/server/push'; import { runBackup } from '$lib/server/backup'; import { publishMusicClip } from '$lib/server/music/publish'; import { deleteWaveform } from '$lib/server/audio/waveform'; -import { getDueQueueEntries, publishQueuedClip } from '$lib/server/queue'; import { createLogger } from '$lib/server/logger'; import { v4 as uuid } from 'uuid'; @@ -22,7 +21,6 @@ let lastReminderDate: string | null = null; const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour const TRIM_CHECK_INTERVAL = 10 * 1000; // 10 seconds -const QUEUE_CHECK_INTERVAL = 30 * 1000; // 30 seconds const REMINDER_HOUR = 9; // 9 AM server time const BACKUP_HOUR = 2; // 2 AM server time const REMINDER_BODIES = [ @@ -41,11 +39,9 @@ export function startScheduler(): void { checkAndSendReminders(); checkAndRunBackup(); checkAndAutoPublish(); - checkAndPublishQueued(); setInterval(checkAndSendReminders, CHECK_INTERVAL); setInterval(checkAndRunBackup, CHECK_INTERVAL); setInterval(checkAndAutoPublish, TRIM_CHECK_INTERVAL); - setInterval(checkAndPublishQueued, QUEUE_CHECK_INTERVAL); log.info('scheduler started'); } @@ -163,21 +159,6 @@ async function sendDailyReminders(): Promise { } } -function checkAndPublishQueued(): void { - try { - const dueEntries = getDueQueueEntries(); - for (const entry of dueEntries) { - try { - publishQueuedClip(entry.id); - } catch (err) { - log.error({ err, clipId: entry.clipId }, 'queue publish failed'); - } - } - } catch (err) { - log.error({ err }, 'queue check failed'); - } -} - async function checkAndAutoPublish(): Promise { try { const now = new Date(); diff --git a/src/routes/api/queue/+server.ts b/src/routes/api/queue/+server.ts index 749d158..38f6d21 100644 --- a/src/routes/api/queue/+server.ts +++ b/src/routes/api/queue/+server.ts @@ -1,19 +1,19 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { withAuth } from '$lib/server/api-utils'; -import { getUserQueue, clearQueue } from '$lib/server/queue'; -import { formatRelativeTime } from '$lib/server/share-limit'; +import { withAuth, safeInt } from '$lib/server/api-utils'; +import { getUserQueue, clearQueue, getQueueCount } from '$lib/server/queue'; -export const GET: RequestHandler = withAuth(async (_event, { user }) => { - const entries = getUserQueue(user.id, user.groupId); +export const GET: RequestHandler = withAuth(async ({ url }, { user }) => { + const limit = safeInt(url.searchParams.get('limit'), 20, 50); + const offset = safeInt(url.searchParams.get('offset'), 0); + const order = url.searchParams.get('order') === 'oldest' ? 'oldest' : 'newest'; + + const entries = getUserQueue(user.id, user.groupId, { limit, offset, order }); + const total = getQueueCount(user.id, user.groupId); - const now = Date.now(); const queue = entries.map((e) => ({ id: e.id, clipId: e.clipId, - position: e.position, - scheduledAt: e.scheduledAt.toISOString(), - sharesIn: formatRelativeTime(Math.max(0, e.scheduledAt.getTime() - now)), createdAt: e.createdAt.toISOString(), title: e.title, originalUrl: e.originalUrl, @@ -23,7 +23,7 @@ export const GET: RequestHandler = withAuth(async (_event, { user }) => { thumbnailPath: e.thumbnailPath })); - return json({ queue }); + return json({ queue, total, hasMore: offset + queue.length < total }); }); export const DELETE: RequestHandler = withAuth(async (_event, { user }) => { diff --git a/src/routes/api/queue/[id]/move-to-top/+server.ts b/src/routes/api/queue/[id]/move-to-top/+server.ts deleted file mode 100644 index f66d459..0000000 --- a/src/routes/api/queue/[id]/move-to-top/+server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { withAuth, notFound } from '$lib/server/api-utils'; -import { moveToTop } from '$lib/server/queue'; - -export const POST: RequestHandler = withAuth(async ({ params }, { user }) => { - const success = moveToTop(params.id, user.id, user.groupId); - if (!success) return notFound('Queue entry not found'); - return json({ ok: true }); -}); diff --git a/src/routes/api/queue/post/+server.ts b/src/routes/api/queue/post/+server.ts new file mode 100644 index 0000000..81d0b04 --- /dev/null +++ b/src/routes/api/queue/post/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { withAuth, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; +import { postQueuedClips, getQueueCount } from '$lib/server/queue'; +import { getCredits } from '$lib/server/credits'; + +/** + * Post selected queued clips to the feed. Spends one credit per clip; the server + * self-limits to the user's available credits (extra ids are silently skipped once + * credits run out). Only the caller's own 'queued' entries are eligible. + */ +export const POST: RequestHandler = withAuth(async ({ request }, { user }) => { + const body = await parseBody<{ ids?: unknown }>(request); + if (isResponse(body)) return body; + + const ids = Array.isArray(body.ids) + ? body.ids.filter((v): v is string => typeof v === 'string') + : []; + if (ids.length === 0) return badRequest('No queue entries provided'); + + const posted = await postQueuedClips(user.id, user.groupId, ids); + + return json({ + posted, + credits: getCredits(user.id), + queueLength: getQueueCount(user.id, user.groupId) + }); +}); diff --git a/src/routes/api/queue/reorder/+server.ts b/src/routes/api/queue/reorder/+server.ts deleted file mode 100644 index 4bdea3a..0000000 --- a/src/routes/api/queue/reorder/+server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { withAuth, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; -import { reorderQueue } from '$lib/server/queue'; - -export const PATCH: RequestHandler = withAuth(async ({ request }, { user }) => { - const body = await parseBody<{ orderedIds?: string[] }>(request); - if (isResponse(body)) return body; - - const { orderedIds } = body; - if (!Array.isArray(orderedIds) || orderedIds.length === 0) { - return badRequest('orderedIds must be a non-empty array of queue entry IDs.'); - } - - const success = reorderQueue(user.id, user.groupId, orderedIds); - if (!success) return badRequest('Invalid queue entry IDs or count mismatch.'); - - return json({ ok: true }); -}); From 96d096c1c0e4a1bfe656605fd43ed6dbad967ed7 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:41:03 -0500 Subject: [PATCH 04/11] feat(clips): credit-gated posting + watch-to-earn wiring POST /api/clips and /api/clips/share post directly when the uploader has a credit and an empty queue, else enqueue (credit spent at publish). The watched handler earns a credit on the first watch of another member's clip and returns the new balance. --- src/routes/api/clips/+server.ts | 107 +++++-------------- src/routes/api/clips/[id]/watched/+server.ts | 21 +++- src/routes/api/clips/share/+server.ts | 76 +++---------- 3 files changed, 60 insertions(+), 144 deletions(-) diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 6c2eec5..25994e5 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -33,8 +33,8 @@ import { safeInt } from '$lib/server/api-utils'; import { extractMentions, notifyMentions } from '$lib/server/mentions'; -import { checkSharePacing, formatRelativeTime } from '$lib/server/share-limit'; -import { enqueueClip } from '$lib/server/queue'; +import { enqueueClip, getQueueCount } from '$lib/server/queue'; +import { getCredits } from '$lib/server/credits'; import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); @@ -381,61 +381,6 @@ async function autoPostComment( } } -type QueueResult = { queued: false } | { queued: true; scheduledAt: Date; position: number }; - -function tryEnqueue( - pacing: import('$lib/server/share-limit').SharePacingResult, - clipId: string, - userId: string, - groupId: string, - cooldownMinutes: number, - burst: number -): QueueResult | Response { - if (pacing.mode !== 'queue' || !pacing.queued) return { queued: false }; - // Use clout-adjusted values when available - const effectiveCooldown = pacing.clout?.cooldownMinutes ?? cooldownMinutes; - const effectiveBurst = pacing.clout?.burstSize ?? burst; - const queueLimit = pacing.clout?.queueLimit ?? null; - const entry = enqueueClip(clipId, userId, groupId, effectiveCooldown, effectiveBurst, queueLimit); - if (!entry) { - const tierMsg = pacing.clout ? ` You're at ${pacing.clout.tierName} tier.` : ''; - const limit = pacing.clout?.queueLimit ?? 10; - return json( - { - error: `Queue full (${limit}/${limit}).${tierMsg} Share clips that get reactions to unlock more capacity.`, - queueFull: true, - tier: pacing.clout?.tier - }, - { status: 429 } - ); - } - return { queued: true, scheduledAt: entry.scheduledAt, position: entry.position }; -} - -function buildClipResponse( - clipId: string, - contentType: string, - pacing: import('$lib/server/share-limit').SharePacingResult, - qr: QueueResult -): Record { - const resp: Record = { - clip: { id: clipId, status: 'downloading', contentType } - }; - if (qr.queued) - Object.assign(resp, { - queued: true, - scheduledAt: qr.scheduledAt.toISOString(), - queuePosition: qr.position, - sharesIn: formatRelativeTime(Math.max(0, qr.scheduledAt.getTime() - Date.now())) - }); - if (pacing.mode === 'daily_cap') - Object.assign(resp, { - shareCountToday: pacing.limitCheck.shareCountToday + 1, - dailyShareLimit: pacing.limitCheck.dailyShareLimit - }); - return resp; -} - async function handleExistingClip( existing: typeof clips.$inferSelect, validUrl: string, @@ -450,19 +395,15 @@ async function handleExistingClip( ); } const qe = db - .select({ scheduledAt: clipQueue.scheduledAt }) + .select({ id: clipQueue.id }) .from(clipQueue) .where(eq(clipQueue.clipId, existing.id)) .get(); const resp: Record = { - error: 'This link has already been added to the feed.', + error: 'This link has already been added.', addedBy: existing.addedBy }; - if (qe) - Object.assign(resp, { - inQueue: true, - sharesIn: formatRelativeTime(Math.max(0, qe.scheduledAt.getTime() - Date.now())) - }); + if (qe) Object.assign(resp, { inQueue: true }); return json(resp, { status: 409 }); } @@ -489,18 +430,19 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } const contentType = getContentType(platform); const normalizedUrl = normalizeUrl(validUrl); - const tz = typeof body.tz === 'string' ? body.tz : null; - const pacing = checkSharePacing(user.id, user.groupId, group, tz, { - cloutTier: user.cloutTier, - cloutTierChangedAt: user.cloutTierChangedAt - }); - if (pacing.mode === 'daily_cap' && pacing.response) return pacing.response; - const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, user.groupId), eq(clips.originalUrl, normalizedUrl)) }); if (existing) return handleExistingClip(existing, validUrl, title); + // Reciprocity decision: a clip posts straight to the feed only if you have a credit to + // spend AND nothing is already waiting in your queue. Otherwise it's held in the queue + // to be posted later (by spending a credit via the post gate). The credit itself is + // spent at PUBLISH time inside setClipReady — so a failed download never costs anything. + const hasCredit = getCredits(user.id) >= 1; + const queueEmpty = getQueueCount(user.id, user.groupId) === 0; + const directPost = hasCredit && queueEmpty; + const clipId = uuid(); const now = new Date(); try { @@ -527,17 +469,20 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } return json({ error: 'Failed to create clip' }, { status: 500 }); } - const queueResult = tryEnqueue( - pacing, - clipId, - user.id, - user.groupId, - group.shareCooldownMinutes, - group.shareBurst - ); - if (queueResult instanceof Response) return queueResult; + // A queue row marks the clip as "held"; its presence is how setClipReady tells a queued + // clip from a direct post when the download finishes. + if (!directPost) enqueueClip(clipId, user.id, user.groupId); await startDownload(clipId, validUrl, contentType, 'new clip'); if (message) await autoPostComment(clipId, user, message, now); - return json(buildClipResponse(clipId, contentType, pacing, queueResult), { status: 201 }); + + return json( + { + clip: { id: clipId, status: 'downloading', contentType }, + queued: !directPost, + credits: getCredits(user.id), + queueLength: getQueueCount(user.id, user.groupId) + }, + { status: 201 } + ); }); diff --git a/src/routes/api/clips/[id]/watched/+server.ts b/src/routes/api/clips/[id]/watched/+server.ts index 0101e2c..c4320b8 100644 --- a/src/routes/api/clips/[id]/watched/+server.ts +++ b/src/routes/api/clips/[id]/watched/+server.ts @@ -4,8 +4,9 @@ import { db } from '$lib/server/db'; import { watched } from '$lib/server/db/schema'; import { and, eq, sql } from 'drizzle-orm'; import { withClipAuth } from '$lib/server/api-utils'; +import { getCredits, grantWatchCredit } from '$lib/server/credits'; -export const POST: RequestHandler = withClipAuth(async ({ params, request }, { user }) => { +export const POST: RequestHandler = withClipAuth(async ({ params, request }, { user, clip }) => { // Parse optional watchPercent from body let watchPercent: number | null = null; try { @@ -17,6 +18,18 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u // No body or invalid JSON — backward compatible with no-body calls } + // A watch earns a reciprocity credit only the FIRST time you watch SOMEONE ELSE'S clip. + // Detect "new watch" before the upsert; self-watches (your own uploads, auto-marked at + // 100%) never earn. See $lib/server/credits. + const isSelf = clip.addedBy === user.id; + const alreadyWatched = isSelf + ? true + : !!db + .select({ clipId: watched.clipId }) + .from(watched) + .where(and(eq(watched.clipId, params.id), eq(watched.userId, user.id))) + .get(); + await db .insert(watched) .values({ @@ -36,7 +49,11 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u } }); - return json({ watched: true }); + if (!isSelf && !alreadyWatched) { + grantWatchCredit(user.id, clip.groupId); + } + + return json({ watched: true, credits: getCredits(user.id) }); }); // PATCH: update watchPercent only — creates the row if needed but does NOT count as "watched" diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index 3707dde..376e900 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -16,8 +16,8 @@ import { startDownload } from '$lib/server/clip-download'; import { getActiveProvider } from '$lib/server/providers/registry'; import { createLogger } from '$lib/server/logger'; import { authenticateShortcutToken } from '$lib/server/shortcut-auth'; -import { checkSharePacing, formatRelativeTime } from '$lib/server/share-limit'; -import { enqueueClip } from '$lib/server/queue'; +import { enqueueClip, getQueueCount } from '$lib/server/queue'; +import { getCredits } from '$lib/server/credits'; const log = createLogger('share'); @@ -102,33 +102,6 @@ async function validateShareRequest( return { matchedUser, group, platform, videoUrl }; } -/** Try to enqueue a clip. Returns queue info or an error response if queue is full. */ -function tryEnqueueShare( - pacing: import('$lib/server/share-limit').SharePacingResult, - clipId: string, - userId: string, - groupId: string, - cooldownMinutes: number, - burst: number -): { queued: false } | { queued: true; sharesIn: string } | Response { - if (pacing.mode !== 'queue' || !pacing.queued) return { queued: false }; - const effectiveCooldown = pacing.clout?.cooldownMinutes ?? cooldownMinutes; - const effectiveBurst = pacing.clout?.burstSize ?? burst; - const queueLimit = pacing.clout?.queueLimit ?? null; - const entry = enqueueClip(clipId, userId, groupId, effectiveCooldown, effectiveBurst, queueLimit); - if (!entry) { - const limit = pacing.clout?.queueLimit ?? 10; - const tierMsg = pacing.clout ? ` (${pacing.clout.tierName} tier)` : ''; - return shareResponse(false, `❌ Queue full${tierMsg} (${limit}/${limit}).`, 429, { - queueFull: true - }); - } - return { - queued: true, - sharesIn: formatRelativeTime(Math.max(0, entry.scheduledAt.getTime() - Date.now())) - }; -} - export const POST: RequestHandler = async ({ request, url, locals }) => { const body = await parseShareBody(request); if (body instanceof Response) return body; @@ -142,18 +115,6 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { const contentType = getContentType(platform); const normalizedVideoUrl = normalizeUrl(videoUrl); - // 6.5. Check share pacing (off / daily_cap / queue) - const tz = typeof body.tz === 'string' ? body.tz : null; - const pacing = checkSharePacing(matchedUser.id, group.id, group, tz); - if (pacing.mode === 'daily_cap' && pacing.response) { - return shareResponse( - false, - `❌ Daily limit reached (${pacing.limitCheck.shareCountToday}/${pacing.limitCheck.dailyShareLimit}). Resets soon.`, - 429, - { limitReached: true } - ); - } - // 7. Duplicate check const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, group.id), eq(clips.originalUrl, normalizedVideoUrl)) @@ -169,6 +130,11 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { return shareResponse(false, '❌ This clip has already been shared!', 409); } + // Reciprocity: post straight to the feed only with a credit in hand and an empty queue; + // otherwise hold it in the queue. Credit is spent at publish time (setClipReady). + const directPost = + getCredits(matchedUser.id) >= 1 && getQueueCount(matchedUser.id, group.id) === 0; + // 8. Create clip + auto-watched in a transaction so both succeed or fail together const clipId = uuid(); const now = new Date(); @@ -203,16 +169,8 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { return shareResponse(false, 'Something went wrong. Try sharing again.', 500); } - // Enqueue if burst exhausted in queue mode - const queueInfo = tryEnqueueShare( - pacing, - clipId, - matchedUser.id, - group.id, - group.shareCooldownMinutes, - group.shareBurst - ); - if (queueInfo instanceof Response) return queueInfo; + // A queue row marks the clip as held; its absence means direct-post (spent at publish). + if (!directPost) enqueueClip(clipId, matchedUser.id, group.id); // Async download (skip trim for shortcut shares — no UI available) await startDownload(clipId, videoUrl, contentType, 'new clip', { skipTrim: true }); @@ -220,18 +178,14 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { // Record legacy share timestamp for upgrade banner db.update(users).set({ lastLegacyShareAt: now }).where(eq(users.id, matchedUser.id)).run(); - const message = queueInfo.queued - ? `✅ Clip queued! Shares in ~${queueInfo.sharesIn}.` - : '✅ Clip shared!'; + const message = directPost + ? '✅ Clip shared!' + : '✅ Saved to your queue — post it from Scrolly when you’re ready.'; return shareResponse(true, message, 201, { clipId, - ...(queueInfo.queued ? { queued: true, sharesIn: queueInfo.sharesIn } : {}), - ...(pacing.mode === 'daily_cap' - ? { - shareCountToday: pacing.limitCheck.shareCountToday + 1, - dailyShareLimit: pacing.limitCheck.dailyShareLimit - } - : {}) + queued: !directPost, + credits: getCredits(matchedUser.id), + queueLength: getQueueCount(matchedUser.id, group.id) }); }; From c5a12a52b45da310f90c178a26e2abe9d0ca8702 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:48:53 -0500 Subject: [PATCH 05/11] feat(reciprocity): launch cutoff so backlog doesn't mint credits Adds groups.reciprocity_started_at: only clips created at/after the cutoff earn watch credits. Pre-cutoff backlog clips stay fully watchable but pay out zero, so rolling out reciprocity over an existing feed can't convert the backlog into a flood of posting credits. The cutoff is stamped at group creation and backfilled to deploy time for the existing group on first boot after migration. grantWatchCredit gates on the clip's created_at and only advances the ratio counter on eligible (post-cutoff, non-self) watches. Self-watches and rewatches still earn nothing. --- docs/data-model.md | 20 +- src/lib/server/__tests__/credits.test.ts | 201 ++++++++++++++++++ src/lib/server/credits.ts | 39 +++- src/lib/server/db/index.ts | 13 ++ .../db/migrations/0034_reciprocity_cutoff.sql | 1 + .../server/db/migrations/meta/_journal.json | 7 + src/lib/server/db/schema.ts | 3 + src/routes/api/auth/+server.ts | 1 + src/routes/api/clips/[id]/watched/+server.ts | 2 +- 9 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 src/lib/server/__tests__/credits.test.ts create mode 100644 src/lib/server/db/migrations/0034_reciprocity_cutoff.sql diff --git a/docs/data-model.md b/docs/data-model.md index 1b6a95f..d430807 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -18,11 +18,10 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | download_provider | text | Nullable. Active download provider ID. | | platform_filter_mode | text | Default `'all'`. `'all'` / `'allow'` / `'block'`. | | platform_filter_list | text | Nullable. Comma-separated list of platforms for allow/block filtering. | -| daily_share_limit | integer | Nullable. Max clips per user per calendar day. | -| share_pacing_mode | text | Default `'off'`. `'off'` / `'daily_cap'` / `'queue'`. | -| share_burst | integer | Default 2. Clips per scheduled time slot in queue mode (1–10). | -| share_cooldown_minutes | integer | Default 120. Minutes between clip groups in queue mode. | -| clout_enabled | integer | Boolean (0/1). Default 1. Enables reputation-based queue adjustments. | +| reciprocity_seed | integer | Default 5. Credits a new member starts with. | +| reciprocity_cap | integer | Default 5. Max credits a member can hold at once. | +| reciprocity_ratio | integer | Default 1. Qualifying watches needed to earn one credit. | +| reciprocity_started_at | integer | Nullable. Launch cutoff — only clips created at/after this earn watch credits (backlog stays watchable but pays out zero). Stamped at group creation / first boot after the cutoff migration. | | shortcut_token | text | Nullable, unique. Token for iOS Shortcut clip sharing. | | shortcut_url | text | Nullable. URL for iOS Shortcut integration. | | created_by | text | FK → users.id (host/admin) | @@ -43,9 +42,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | avatar_path | text | Nullable. Path to uploaded profile picture. | | last_legacy_share_at | integer | Nullable. Unix timestamp of last legacy shortcut share. Used for upgrade banner. | | used_new_share_flow | integer | Boolean (0/1). Default 0. Tracks if user has adopted new web view share flow. | -| clout_tier | text | Nullable. Last acknowledged clout tier (for tier change detection). | -| clout_change_shown_at | integer | Nullable. Unix timestamp when tier change modal was last shown. | -| clout_tier_changed_at | integer | Nullable. Unix timestamp when the user's effective clout tier last changed. Used for rank-down protection (4-day cooldown). | +| post_credits | integer | Default 5. Reciprocity credit balance (0..group cap). Earned by watching others' clips, spent by posting. | | removed_at | integer | Nullable. Unix timestamp when removed from group. | | created_at | integer | Unix timestamp | @@ -175,11 +172,9 @@ Unique constraint on `(clip_id, user_id)` — tracks clips dismissed by users in | clip_id | text | FK → clips.id | | user_id | text | FK → users.id | | group_id | text | FK → groups.id | -| position | integer | Order in queue (0-based) | -| scheduled_at | integer | Unix timestamp when clip will be published | | created_at | integer | Unix timestamp | -Index on `(user_id, group_id)` for efficient queue lookups. +Index on `(user_id, group_id)` for efficient queue lookups. The queue is an unlimited personal holding area: clips wait here (status `'queued'`) until the user spends a credit to post them. There is no scheduled/timed publishing — posting is always a deliberate user action. ### push_subscriptions @@ -253,4 +248,5 @@ users 1──∞ verification_codes - **Duplicate URL prevention:** A unique index on `(group_id, original_url)` prevents the same link from being shared twice within a group. - **Music clip trim workflow:** Music clips enter `pending_trim` status after download. The user can trim audio via the trim UI or skip trimming. If neither occurs before `trim_deadline`, the clip auto-publishes to `ready` status via the scheduler. - **Dismissed clips:** The `dismissed_clips` table tracks clips dismissed by users in the catch-up modal. Users can dismiss unwatched clips in bulk, then restore them later from the Skipped Clips viewer in settings. -- **Clout (reputation):** Computed on-demand from `reactions`, `favorites`, and `comments` tables. A user's clout score is the rolling average of per-clip engagement scores (0/1/2) for their last 10 eligible clips. Only clips watched by ≥75% of other group members are eligible. Self-interactions are excluded. Tiers (Fresh/Rising/Viral/Iconic) determine queue cooldown multiplier, burst size, and queue depth limits. Rank-ups apply immediately; rank-downs require the user to have held their current tier for ≥4 days before taking effect. The `clout_enabled` flag on `groups` controls whether clout adjustments are applied. The `clout_tier` and `clout_tier_changed_at` columns on `users` track the effective tier and when it last changed. +- **Reciprocity pacing (watch-to-post):** The single pacing system. Each user holds an integer credit balance (`users.post_credits`, range `0..group.reciprocity_cap`). Watching another member's clip for the first time earns one credit (every `reciprocity_ratio` qualifying watches); self-watches and repeat watches never earn. Posting a clip to the feed spends one credit. New members seed at `reciprocity_seed`. A clip posts straight to the feed only when the uploader has a credit *and* an empty queue; otherwise it waits in `clip_queue` until posted. Credits are spent at publish time, so failed downloads cost nothing. This produces a conservation law — group-wide, total posts can't exceed total watches-of-others — so a prolific poster's ceiling is the rest of the group's output. Replaces the former clout/burst/cooldown/daily-cap systems. +- **Launch cutoff (`groups.reciprocity_started_at`):** Only clips created at/after this timestamp mint watch credits. Clips that predate it remain fully watchable in the feed but pay out zero, so adopting reciprocity over an existing backlog can't convert that backlog into a flood of posting credits. It's stamped at group creation, and backfilled to the deploy time for any pre-existing group on the first boot after the cutoff migration. The ratio counter only advances on eligible (post-cutoff, non-self) watches. diff --git a/src/lib/server/__tests__/credits.test.ts b/src/lib/server/__tests__/credits.test.ts new file mode 100644 index 0000000..49b8e34 --- /dev/null +++ b/src/lib/server/__tests__/credits.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi } from 'vitest'; +import { v4 as uuid } from 'uuid'; +import * as schema from '../db/schema'; + +vi.mock('$lib/server/db', async () => { + const { createTestDb } = await import('../../../../tests/helpers/db'); + return createTestDb(); +}); + +const { db } = await import('$lib/server/db'); +const { getReciprocityConfig, getCredits, spendCredit, earnCredit, grantWatchCredit } = + await import('../credits'); + +function setup( + opts: { seed?: number; cap?: number; ratio?: number; startedAt?: Date | null } = {} +) { + const groupId = uuid(); + const userId = uuid(); + const now = new Date(); + (db as any) + .insert(schema.groups) + .values({ + id: groupId, + name: 'G', + inviteCode: `i-${groupId.slice(0, 6)}`, + accentColor: 'coral', + reciprocitySeed: opts.seed ?? 5, + reciprocityCap: opts.cap ?? 5, + reciprocityRatio: opts.ratio ?? 1, + reciprocityStartedAt: opts.startedAt ?? null, + createdAt: now + }) + .run(); + (db as any) + .insert(schema.users) + .values({ + id: userId, + username: `u-${userId.slice(0, 6)}`, + phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, + groupId, + postCredits: opts.seed ?? 5, + createdAt: now + }) + .run(); + return { groupId, userId }; +} + +function insertUser(groupId: string) { + const id = uuid(); + (db as any) + .insert(schema.users) + .values({ + id, + username: `u-${id.slice(0, 6)}`, + phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, + groupId, + postCredits: 5, + createdAt: new Date() + }) + .run(); + return id; +} + +function insertClip(groupId: string, addedBy: string, createdAt: Date = new Date()) { + const id = uuid(); + (db as any) + .insert(schema.clips) + .values({ + id, + groupId, + addedBy, + originalUrl: `https://x/${id}`, + platform: 'tiktok', + status: 'ready', + contentType: 'video', + createdAt + }) + .run(); + return id; +} + +function watch(clipId: string, userId: string) { + (db as any) + .insert(schema.watched) + .values({ clipId, userId, watchPercent: 100, watchedAt: new Date() }) + .run(); +} + +describe('getReciprocityConfig', () => { + it('returns the group config', () => { + const { groupId } = setup({ seed: 3, cap: 8, ratio: 2 }); + expect(getReciprocityConfig(groupId)).toEqual({ seed: 3, cap: 8, ratio: 2, startedAt: null }); + }); + + it('falls back to 5/5/1 for an unknown group', () => { + expect(getReciprocityConfig('nope')).toEqual({ seed: 5, cap: 5, ratio: 1, startedAt: null }); + }); +}); + +describe('spendCredit', () => { + it('decrements and returns true when credits are available', () => { + const { userId } = setup({ seed: 3 }); + expect(spendCredit(userId)).toBe(true); + expect(getCredits(userId)).toBe(2); + }); + + it('returns false and does not go negative at 0', () => { + const { userId } = setup({ seed: 0 }); + expect(spendCredit(userId)).toBe(false); + expect(getCredits(userId)).toBe(0); + }); + + it('two racing spends at balance 1 — exactly one succeeds (guard)', () => { + const { userId } = setup({ seed: 1 }); + const a = spendCredit(userId); + const b = spendCredit(userId); + expect([a, b].filter(Boolean)).toHaveLength(1); + expect(getCredits(userId)).toBe(0); + }); +}); + +describe('earnCredit', () => { + it('clamps at the cap', () => { + const { groupId, userId } = setup({ seed: 5, cap: 5 }); + earnCredit(userId, groupId); + expect(getCredits(userId)).toBe(5); // already at cap + }); + + it('increments below the cap', () => { + const { groupId, userId } = setup({ seed: 2, cap: 5 }); + expect(earnCredit(userId, groupId)).toBe(3); + }); +}); + +describe('grantWatchCredit', () => { + const RECENT = new Date(); + + it('ratio 1 earns on every qualifying watch', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 1 }); + const other = insertUser(groupId); + const c = insertClip(groupId, other); + watch(c, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(1); + }); + + it('ratio 3 earns only on every third watch', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 3 }); + const other = insertUser(groupId); + // Record 3 non-self watches; only the 3rd grants. + const c1 = insertClip(groupId, other); + const c2 = insertClip(groupId, other); + const c3 = insertClip(groupId, other); + watch(c1, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(0); + watch(c2, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(0); + watch(c3, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(1); + }); +}); + +describe('grantWatchCredit — launch cutoff (Path A)', () => { + const CUTOFF = new Date('2026-01-01T00:00:00Z'); + const BEFORE = new Date('2025-12-25T00:00:00Z'); + const AFTER = new Date('2026-01-02T00:00:00Z'); + + it('a pre-cutoff backlog clip earns nothing', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 1, startedAt: CUTOFF }); + const other = insertUser(groupId); + const backlog = insertClip(groupId, other, BEFORE); + watch(backlog, userId); + expect(grantWatchCredit(userId, groupId, BEFORE)).toBe(0); + }); + + it('a post-cutoff clip earns a credit', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 1, startedAt: CUTOFF }); + const other = insertUser(groupId); + const fresh = insertClip(groupId, other, AFTER); + watch(fresh, userId); + expect(grantWatchCredit(userId, groupId, AFTER)).toBe(1); + }); + + it('pre-cutoff watches do not advance the ratio counter', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 2, startedAt: CUTOFF }); + const other = insertUser(groupId); + // Two backlog watches earn nothing and must not count toward the ratio... + const b1 = insertClip(groupId, other, BEFORE); + const b2 = insertClip(groupId, other, BEFORE); + watch(b1, userId); + expect(grantWatchCredit(userId, groupId, BEFORE)).toBe(0); + watch(b2, userId); + expect(grantWatchCredit(userId, groupId, BEFORE)).toBe(0); + // ...so the 2nd *eligible* watch is what triggers the ratio-2 grant. + const f1 = insertClip(groupId, other, AFTER); + const f2 = insertClip(groupId, other, AFTER); + watch(f1, userId); + expect(grantWatchCredit(userId, groupId, AFTER)).toBe(0); + watch(f2, userId); + expect(grantWatchCredit(userId, groupId, AFTER)).toBe(1); + }); +}); diff --git a/src/lib/server/credits.ts b/src/lib/server/credits.ts index 7199f93..badebe9 100644 --- a/src/lib/server/credits.ts +++ b/src/lib/server/credits.ts @@ -1,6 +1,6 @@ import { db } from '$lib/server/db'; import { users, groups, watched, clips } from '$lib/server/db/schema'; -import { eq, and, ne, sql } from 'drizzle-orm'; +import { eq, and, ne, gte, sql } from 'drizzle-orm'; import { createLogger } from '$lib/server/logger'; const log = createLogger('credits'); @@ -12,9 +12,11 @@ export interface ReciprocityConfig { cap: number; /** How many qualifying watches earn one credit (≥1). */ ratio: number; + /** Launch cutoff: only clips created at/after this earn credits. null = no cutoff. */ + startedAt: Date | null; } -const FALLBACK: ReciprocityConfig = { seed: 5, cap: 5, ratio: 1 }; +const FALLBACK: ReciprocityConfig = { seed: 5, cap: 5, ratio: 1, startedAt: null }; /** * Read a group's reciprocity tuning. Falls back to 5/5/1 if the group is missing. @@ -24,7 +26,8 @@ export function getReciprocityConfig(groupId: string): ReciprocityConfig { .select({ seed: groups.reciprocitySeed, cap: groups.reciprocityCap, - ratio: groups.reciprocityRatio + ratio: groups.reciprocityRatio, + startedAt: groups.reciprocityStartedAt }) .from(groups) .where(eq(groups.id, groupId)) @@ -34,7 +37,8 @@ export function getReciprocityConfig(groupId: string): ReciprocityConfig { return { seed: group.seed, cap: group.cap, - ratio: Math.max(1, group.ratio) + ratio: Math.max(1, group.ratio), + startedAt: group.startedAt ?? null }; } @@ -92,14 +96,23 @@ export function refundCredit(userId: string, groupId: string): number { * Only call this for a NEW (first-time) watch of ANOTHER member's clip — the caller * is responsible for excluding self-watches and repeat watches. * - * With ratio 1 every qualifying watch earns. With ratio N, a credit is granted on - * every Nth qualifying watch, counted over the user's lifetime non-self watches - * (the just-inserted watch is included in the count). + * A watch only qualifies if the clip was created at/after the group's launch cutoff + * (`reciprocityStartedAt`). Pre-cutoff backlog clips are freely watchable but mint nothing, + * so rollout can't convert the existing backlog into a flood of posting credits. + * + * With ratio 1 every qualifying watch earns. With ratio N, a credit is granted on every + * Nth qualifying watch, counted over the user's lifetime eligible (post-cutoff, non-self) + * watches (the just-inserted watch is included in the count). * * Returns the user's balance after any grant. */ -export function grantWatchCredit(userId: string, groupId: string): number { - const { ratio } = getReciprocityConfig(groupId); +export function grantWatchCredit(userId: string, groupId: string, clipCreatedAt: Date): number { + const { ratio, startedAt } = getReciprocityConfig(groupId); + + // Backlog clips (created before the launch cutoff) never earn. + if (startedAt && clipCreatedAt < startedAt) { + return getCredits(userId); + } if (ratio <= 1) { const credits = earnCredit(userId, groupId); @@ -107,12 +120,16 @@ export function grantWatchCredit(userId: string, groupId: string): number { return credits; } - // Count this user's lifetime qualifying (non-self) watches, then earn on every Nth. + // Count this user's lifetime eligible (post-cutoff, non-self) watches, then earn on every + // Nth. Pre-cutoff watches are excluded so the ratio only advances on credit-bearing views. + const eligibility = startedAt + ? and(eq(watched.userId, userId), ne(clips.addedBy, userId), gte(clips.createdAt, startedAt)) + : and(eq(watched.userId, userId), ne(clips.addedBy, userId)); const [row] = db .select({ count: sql`count(*)` }) .from(watched) .innerJoin(clips, eq(watched.clipId, clips.id)) - .where(and(eq(watched.userId, userId), ne(clips.addedBy, userId))) + .where(eligibility) .all(); const qualifyingWatches = row?.count ?? 0; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index e59043c..e4c4b0d 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -172,6 +172,18 @@ for (const g of groupsWithoutToken) { db.update(schema.groups).set({ shortcutToken: uuid() }).where(eq(schema.groups.id, g.id)).run(); } +// Stamp the reciprocity launch cutoff for any group that predates it. This runs once on the +// first boot after the 0034 migration: the existing backlog (clips created before now) becomes +// freely watchable but earns zero credits, so rollout can't mint a flood of posting credits. +const cutoffBackfill = db + .update(schema.groups) + .set({ reciprocityStartedAt: new Date() }) + .where(isNull(schema.groups.reciprocityStartedAt)) + .run(); +if (cutoffBackfill.changes > 0) { + log.info({ groups: cutoffBackfill.changes }, 'Stamped reciprocity launch cutoff'); +} + // Remove favorites that were auto-created by negative-only reactions (👎, ❓) // A favorite is removed if the user has reactions on that clip but ALL are negative const NEGATIVE_EMOJIS = ['👎', '❓']; @@ -205,6 +217,7 @@ if (groupCount.length === 0) { name: process.env.GROUP_NAME || 'Scrolly', inviteCode, shortcutToken: uuid(), + reciprocityStartedAt: new Date(), createdAt: new Date() }) .run(); diff --git a/src/lib/server/db/migrations/0034_reciprocity_cutoff.sql b/src/lib/server/db/migrations/0034_reciprocity_cutoff.sql new file mode 100644 index 0000000..dae06b7 --- /dev/null +++ b/src/lib/server/db/migrations/0034_reciprocity_cutoff.sql @@ -0,0 +1 @@ +ALTER TABLE `groups` ADD `reciprocity_started_at` integer; diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json index 62eb516..f62d889 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1780072362388, "tag": "0033_reciprocity_pacing", "breakpoints": true + }, + { + "idx": 34, + "version": "6", + "when": 1780072362389, + "tag": "0034_reciprocity_cutoff", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index b5b2e2e..2fe529e 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -14,6 +14,9 @@ export const groups = sqliteTable('groups', { reciprocitySeed: integer('reciprocity_seed').notNull().default(5), reciprocityCap: integer('reciprocity_cap').notNull().default(5), reciprocityRatio: integer('reciprocity_ratio').notNull().default(1), + // Launch cutoff: only clips created at/after this time mint watch credits. Clips that + // predate it stay fully watchable but pay out zero, so the backlog can't be farmed. + reciprocityStartedAt: integer('reciprocity_started_at', { mode: 'timestamp' }), shortcutToken: text('shortcut_token').unique(), shortcutUrl: text('shortcut_url'), createdBy: text('created_by'), diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index c51aa9d..fb2f54b 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -188,6 +188,7 @@ async function ensureDevUser(phone: string, existingUser: typeof users.$inferSel name: 'Dev Group', inviteCode: 'dev', shortcutToken: uuid(), + reciprocityStartedAt: new Date(), createdAt: new Date() }); group = await db.query.groups.findFirst({ where: eq(groups.id, groupId) }); diff --git a/src/routes/api/clips/[id]/watched/+server.ts b/src/routes/api/clips/[id]/watched/+server.ts index c4320b8..e4f9906 100644 --- a/src/routes/api/clips/[id]/watched/+server.ts +++ b/src/routes/api/clips/[id]/watched/+server.ts @@ -50,7 +50,7 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u }); if (!isSelf && !alreadyWatched) { - grantWatchCredit(user.id, clip.groupId); + grantWatchCredit(user.id, clip.groupId, clip.createdAt); } return json({ watched: true, credits: getCredits(user.id) }); From c67ceea27b0dec5ebe584064f31d620ec66af88f Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:52:09 -0500 Subject: [PATCH 06/11] refactor: remove clout system and legacy pacing, wire reciprocity UI Deletes the clout module/API/components/tests, the share-limit module, and the share-pacing + daily-share-limit endpoints and their pickers. Settings now uses ReciprocityPicker; the root layout drops CloutChangeModal; the app layout drops the clout-tier poll and seeds the credit store + queue count; the me header shows credit dots instead of a rank badge. Adds the credit store and the CreditDots/CreditInfoModal primitives. --- src/lib/components/CloutChangeModal.svelte | 538 ------------------ src/lib/components/CloutTipsView.svelte | 436 -------------- src/lib/components/CreditDots.svelte | 73 +++ src/lib/components/CreditInfoModal.svelte | 100 ++++ src/lib/components/QueueCloutBanner.svelte | 110 ---- src/lib/components/ShareLimitDots.svelte | 83 --- .../settings/DailyShareLimitPicker.svelte | 137 ----- .../settings/ReciprocityPicker.svelte | 162 ++++++ .../settings/SharePacingPicker.svelte | 454 --------------- src/lib/server/__tests__/clout.test.ts | 168 ------ src/lib/server/__tests__/share-limit.test.ts | 208 ------- src/lib/server/clout.ts | 300 ---------- src/lib/server/share-limit.ts | 242 -------- src/lib/stores/cloutChange.ts | 13 - src/lib/stores/credits.ts | 24 + src/routes/(app)/+layout.svelte | 55 +- src/routes/(app)/me/+page.svelte | 59 +- src/routes/(app)/settings/+page.svelte | 12 +- src/routes/+layout.svelte | 2 - src/routes/api/__tests__/clout.test.ts | 75 --- src/routes/api/clout/+server.ts | 105 ---- .../api/group/daily-share-limit/+server.ts | 26 - src/routes/api/group/share-pacing/+server.ts | 83 --- 23 files changed, 378 insertions(+), 3087 deletions(-) delete mode 100644 src/lib/components/CloutChangeModal.svelte delete mode 100644 src/lib/components/CloutTipsView.svelte create mode 100644 src/lib/components/CreditDots.svelte create mode 100644 src/lib/components/CreditInfoModal.svelte delete mode 100644 src/lib/components/QueueCloutBanner.svelte delete mode 100644 src/lib/components/ShareLimitDots.svelte delete mode 100644 src/lib/components/settings/DailyShareLimitPicker.svelte create mode 100644 src/lib/components/settings/ReciprocityPicker.svelte delete mode 100644 src/lib/components/settings/SharePacingPicker.svelte delete mode 100644 src/lib/server/__tests__/clout.test.ts delete mode 100644 src/lib/server/__tests__/share-limit.test.ts delete mode 100644 src/lib/server/clout.ts delete mode 100644 src/lib/server/share-limit.ts delete mode 100644 src/lib/stores/cloutChange.ts create mode 100644 src/lib/stores/credits.ts delete mode 100644 src/routes/api/__tests__/clout.test.ts delete mode 100644 src/routes/api/clout/+server.ts delete mode 100644 src/routes/api/group/daily-share-limit/+server.ts delete mode 100644 src/routes/api/group/share-pacing/+server.ts diff --git a/src/lib/components/CloutChangeModal.svelte b/src/lib/components/CloutChangeModal.svelte deleted file mode 100644 index f0d7e3a..0000000 --- a/src/lib/components/CloutChangeModal.svelte +++ /dev/null @@ -1,538 +0,0 @@ - - -{#if change} - - -
- -
0 ? `translateY(${dragY}px)` : undefined} - onclick={(e) => e.stopPropagation()} - > - - -
- {#if !showTips} - {@const final = TIER_INFO[change.newTier] ?? TIER_INFO.fresh} -
-
- {#key displayTier.icon} - {displayTier.label} - {/key} -
-

{final.label}

-

- {#if isSameRank} - Your rank - {:else if isRankUp} - You ranked up! - {:else} - Your rank changed - {/if} -

-

- {#if isSameRank && change.newTier === 'iconic'} - Your clips hit different. Maximum speed unlocked. - {:else if isSameRank && change.newTier === 'viral'} - The group loves your taste. Keep it coming. - {:else if isSameRank && change.newTier === 'rising'} - You're building momentum. Keep sharing hits. - {:else if isSameRank} - Share clips the group engages with to climb up. - {:else if change.newTier === 'iconic'} - Your clips hit different. Maximum speed unlocked. - {:else if change.newTier === 'viral' && isRankUp} - The group loves your taste. Keep it coming. - {:else if change.newTier === 'viral'} - Still solid — get more comments to climb back. - {:else if change.newTier === 'rising' && isRankUp} - You're building momentum. Nice picks. - {:else if change.newTier === 'rising'} - Share clips the group reacts to and you'll bounce back. - {:else if isRankUp} - Your clips are starting to land. - {:else} - Share clips the group engages with to climb back up. - {/if} -

-
- {formatCooldown(change.cooldownMinutes)} between clips - · - {change.burstSize} per burst - · - {change.queueLimit ?? '∞'} queue depth -
-
- - {#if change.newTier !== 'iconic'} - - {/if} - {:else if tipsData} - - {/if} -
-
-{/if} - - diff --git a/src/lib/components/CloutTipsView.svelte b/src/lib/components/CloutTipsView.svelte deleted file mode 100644 index 4d0976b..0000000 --- a/src/lib/components/CloutTipsView.svelte +++ /dev/null @@ -1,436 +0,0 @@ - - -
- {#if nextTier} - {@const currentIdx = TIER_ORDER.indexOf(currentTier)} - {@const nextIdx = TIER_ORDER.indexOf(nextTier.tier)} -
-
- {#each TIER_ORDER as tier, i (tier)} - {@const isCurrent = i === currentIdx} - {@const isNext = i === nextIdx} - {@const isPast = i < currentIdx} - - -
nextIdx} - class:selected={selectedTier === tier} - onclick={() => toggleTier(tier)} - > -
- {TIER_INFO[tier]?.label - {#if isCurrent} - YOU - {/if} -
- {TIER_INFO[tier]?.label} -
- {#if i < TIER_ORDER.length - 1} -
- {/if} - {/each} -
- {#if selectedTier} - {@const abilities = TIER_ABILITIES[selectedTier]} -
-
- {abilities.burst} - per burst -
-
-
- {formatCooldown( - Math.round(baseCooldownMinutes * abilities.cooldownMultiplier) - )} - cooldown -
-
-
- {abilities.queueLimit ?? '∞'} - queue depth -
-
- {/if} - {#if neededUpgrades > 0} -

- {neededUpgrades} clip{neededUpgrades === 1 ? '' : 's'} need better engagement to reach - {nextTier.tierName} -

- {/if} -
- -
- -
-
- - Nothing - No engagement -
-
- - Reaction - Or a favorite -
-
- - Both - Reaction + comment -
-
-
- {:else} - {@const currentTierInfo = TIER_INFO[currentTier]} -
-
- {currentTierInfo?.label -
-

You're at the top

-

Maximum speed unlocked. Keep sharing clips the group loves.

-
- {/if} -
- - diff --git a/src/lib/components/CreditDots.svelte b/src/lib/components/CreditDots.svelte new file mode 100644 index 0000000..25097cf --- /dev/null +++ b/src/lib/components/CreditDots.svelte @@ -0,0 +1,73 @@ + + + + +{#if showInfo} + (showInfo = false)} /> +{/if} + + diff --git a/src/lib/components/CreditInfoModal.svelte b/src/lib/components/CreditInfoModal.svelte new file mode 100644 index 0000000..2a02ec5 --- /dev/null +++ b/src/lib/components/CreditInfoModal.svelte @@ -0,0 +1,100 @@ + + +
e.key === 'Escape' && onclose()} +> + +
+ + diff --git a/src/lib/components/QueueCloutBanner.svelte b/src/lib/components/QueueCloutBanner.svelte deleted file mode 100644 index 04d029e..0000000 --- a/src/lib/components/QueueCloutBanner.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -
-
- {clout.tierName} -
- {clout.tierName} - - {formatCooldown(clout.cooldownMinutes)} between clips · {clout.burstSize} per burst - -
-
- {#if clout.breakdown.length > 0} -
- {#each clout.breakdown as entry (entry.clipId)} - 0} class:full={entry.score === 2}> - {/each} - - {clout.breakdown.filter((b) => b.score > 0).length}/{clout.breakdown.length} got reactions - -
- {:else if clout.score === -1} - Share more clips to build your score - {/if} -
- - diff --git a/src/lib/components/ShareLimitDots.svelte b/src/lib/components/ShareLimitDots.svelte deleted file mode 100644 index a2a0f7d..0000000 --- a/src/lib/components/ShareLimitDots.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/src/lib/components/settings/DailyShareLimitPicker.svelte b/src/lib/components/settings/DailyShareLimitPicker.svelte deleted file mode 100644 index c1c9b78..0000000 --- a/src/lib/components/settings/DailyShareLimitPicker.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - -
-
- - {#if inputValue !== ''} - per day - {/if} -
-

{description}

-
- - diff --git a/src/lib/components/settings/ReciprocityPicker.svelte b/src/lib/components/settings/ReciprocityPicker.svelte new file mode 100644 index 0000000..d215845 --- /dev/null +++ b/src/lib/components/settings/ReciprocityPicker.svelte @@ -0,0 +1,162 @@ + + +
+
+ Starting credits +
+ +
+
+ +
+ Max credits +
+ +
+
+ +
+ Watches per credit +
+ +
+
+ +

{description}

+
+ + diff --git a/src/lib/components/settings/SharePacingPicker.svelte b/src/lib/components/settings/SharePacingPicker.svelte deleted file mode 100644 index 04568b2..0000000 --- a/src/lib/components/settings/SharePacingPicker.svelte +++ /dev/null @@ -1,454 +0,0 @@ - - -
-
- -
- -
- - {#if mode === 'daily_cap'} -
- -
- {/if} -
- -
- - {#if mode === 'queue'} -
- -
- Queue spacing -
- {#each cooldownOptions as opt (opt.value)} - - {/each} -
-
-
-
- Reputation adjustments - Adjust pacing based on engagement -
- -
-
- {/if} -
-
- - diff --git a/src/lib/server/__tests__/clout.test.ts b/src/lib/server/__tests__/clout.test.ts deleted file mode 100644 index d5709d0..0000000 --- a/src/lib/server/__tests__/clout.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { v4 as uuid } from 'uuid'; -import * as schema from '../db/schema'; - -vi.mock('$lib/server/db', async () => { - const { createTestDb } = await import('../../../../tests/helpers/db'); - return createTestDb(); -}); - -const { db } = await import('$lib/server/db'); -const { TIERS, getCloutTier, getNextTier, getEffectiveTier, getCloutScore } = - await import('../clout'); - -describe('getCloutTier', () => { - it('classifies fresh', () => { - expect(getCloutTier(0).key).toBe('fresh'); - expect(getCloutTier(0.39).key).toBe('fresh'); - }); - - it('classifies rising', () => { - expect(getCloutTier(0.4).key).toBe('rising'); - expect(getCloutTier(0.69).key).toBe('rising'); - }); - - it('classifies viral', () => { - expect(getCloutTier(0.7).key).toBe('viral'); - expect(getCloutTier(0.99).key).toBe('viral'); - }); - - it('classifies iconic at 1.0+', () => { - expect(getCloutTier(1.0).key).toBe('iconic'); - expect(getCloutTier(2.0).key).toBe('iconic'); - }); -}); - -describe('getNextTier', () => { - it('returns rising as next from fresh', () => { - expect(getNextTier('fresh')?.key).toBe('rising'); - }); - - it('returns iconic as next from viral', () => { - expect(getNextTier('viral')?.key).toBe('iconic'); - }); - - it('returns null at top tier', () => { - expect(getNextTier('iconic')).toBeNull(); - }); -}); - -describe('getEffectiveTier (rank-down protection)', () => { - const now = Date.now(); - - it('rank-up applies immediately', () => { - const r = getEffectiveTier('iconic', 'rising', new Date(now - 60_000)); - expect(r.effectiveTier).toBe('iconic'); - expect(r.tierActuallyChanged).toBe(true); - }); - - it('rank-down within 4-day cooldown keeps stored tier', () => { - const recent = new Date(now - 60_000); - const r = getEffectiveTier('fresh', 'viral', recent); - expect(r.effectiveTier).toBe('viral'); - expect(r.tierActuallyChanged).toBe(false); - }); - - it('rank-down after 4-day cooldown applies', () => { - const old = new Date(now - 5 * 24 * 60 * 60 * 1000); - const r = getEffectiveTier('fresh', 'viral', old); - expect(r.effectiveTier).toBe('fresh'); - expect(r.tierActuallyChanged).toBe(true); - }); - - it('no stored tier returns computed tier', () => { - const r = getEffectiveTier('viral', null, null); - expect(r.effectiveTier).toBe('viral'); - expect(r.tierActuallyChanged).toBe(false); - }); - - it('same tier reports no change', () => { - const r = getEffectiveTier('rising', 'rising', new Date(now)); - expect(r.effectiveTier).toBe('rising'); - expect(r.tierActuallyChanged).toBe(false); - }); -}); - -describe('getCloutScore (DB-driven)', () => { - function setupGroup(memberCount: number) { - const groupId = uuid(); - const userId = uuid(); - const now = new Date(); - (db as any) - .insert(schema.groups) - .values({ - id: groupId, - name: 'C', - inviteCode: `i-${groupId.slice(0, 6)}`, - accentColor: 'coral', - createdAt: now - }) - .run(); - (db as any) - .insert(schema.users) - .values({ - id: userId, - username: `u-${userId.slice(0, 6)}`, - phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, - groupId, - createdAt: now - }) - .run(); - const otherIds: string[] = []; - for (let i = 0; i < memberCount - 1; i++) { - const oid = uuid(); - otherIds.push(oid); - (db as any) - .insert(schema.users) - .values({ - id: oid, - username: `o-${oid.slice(0, 6)}`, - phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, - groupId, - createdAt: now - }) - .run(); - } - return { groupId, userId, otherIds }; - } - - function insertClip(groupId: string, addedBy: string) { - const id = uuid(); - (db as any) - .insert(schema.clips) - .values({ - id, - groupId, - addedBy, - originalUrl: `https://x/${id}`, - platform: 'tiktok', - status: 'ready', - contentType: 'video', - createdAt: new Date() - }) - .run(); - return id; - } - - it('returns Rising tier with score=-1 when fewer than 10 eligible clips', () => { - const { groupId, userId } = setupGroup(2); - insertClip(groupId, userId); - const result = getCloutScore(userId, groupId, 60); - expect(result.tier).toBe('rising'); - expect(result.score).toBe(-1); - }); - - it('applies cooldown multiplier from tier', () => { - const { groupId, userId } = setupGroup(2); - const result = getCloutScore(userId, groupId, 60); - // Rising default = 60 * 2.0 = 120 - expect(result.cooldownMinutes).toBe(120); - }); - - it('records tier and burst values consistent with TIERS table', () => { - const { groupId, userId } = setupGroup(2); - const result = getCloutScore(userId, groupId, 60); - expect(result.burstSize).toBe(TIERS.rising.burst); - expect(result.queueLimit).toBe(TIERS.rising.queueLimit); - }); -}); diff --git a/src/lib/server/__tests__/share-limit.test.ts b/src/lib/server/__tests__/share-limit.test.ts deleted file mode 100644 index f5a62d0..0000000 --- a/src/lib/server/__tests__/share-limit.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { v4 as uuid } from 'uuid'; -import * as schema from '../db/schema'; - -// In-memory DB shared across tests in this file -vi.mock('$lib/server/db', async () => { - const { createTestDb } = await import('../../../../tests/helpers/db'); - return createTestDb(); -}); - -const { db } = await import('$lib/server/db'); -const { - getTodayStart, - checkDailyShareLimit, - formatResetTime, - formatRelativeTime, - enforceDailyShareLimit, - checkSharePacing -} = await import('../share-limit'); - -async function setupGroup() { - const groupId = uuid(); - const userId = uuid(); - const now = new Date(); - (db as any) - .insert(schema.groups) - .values({ - id: groupId, - name: 'Test', - inviteCode: `inv-${groupId.slice(0, 6)}`, - accentColor: 'coral', - createdAt: now - }) - .run(); - (db as any) - .insert(schema.users) - .values({ - id: userId, - username: `u-${userId.slice(0, 6)}`, - phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, - groupId, - createdAt: now - }) - .run(); - return { groupId, userId }; -} - -function insertClip(groupId: string, userId: string, createdAt: Date) { - const id = uuid(); - (db as any) - .insert(schema.clips) - .values({ - id, - groupId, - addedBy: userId, - originalUrl: `https://example.com/${id}`, - platform: 'tiktok', - status: 'ready', - contentType: 'video', - createdAt - }) - .run(); - return id; -} - -describe('getTodayStart', () => { - it('returns UTC midnight when no timezone given', () => { - const start = getTodayStart(); - expect(start.getUTCHours()).toBe(0); - expect(start.getUTCMinutes()).toBe(0); - expect(start.getUTCSeconds()).toBe(0); - }); - - it('falls back to UTC midnight on invalid timezone', () => { - const start = getTodayStart('Mars/Olympus_Mons'); - expect(start.getUTCHours()).toBe(0); - }); - - it('returns a date that is the most recent past midnight', () => { - const start = getTodayStart(); - expect(start.getTime()).toBeLessThanOrEqual(Date.now()); - expect(Date.now() - start.getTime()).toBeLessThan(24 * 60 * 60 * 1000 + 1000); - }); -}); - -describe('checkDailyShareLimit', () => { - it('always allows when limit is null', async () => { - const { groupId, userId } = await setupGroup(); - const result = checkDailyShareLimit(userId, groupId, null); - expect(result.allowed).toBe(true); - expect(result.dailyShareLimit).toBeNull(); - }); - - it('allows when count is below the limit', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - const result = checkDailyShareLimit(userId, groupId, 5); - expect(result.allowed).toBe(true); - expect(result.shareCountToday).toBe(2); - }); - - it('blocks when count is at the limit', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - const result = checkDailyShareLimit(userId, groupId, 3); - expect(result.allowed).toBe(false); - expect(result.shareCountToday).toBe(3); - }); - - it('does not count clips from other users', async () => { - const { groupId, userId } = await setupGroup(); - const other = await setupGroup(); - insertClip(other.groupId, other.userId, new Date()); - const result = checkDailyShareLimit(userId, groupId, 1); - expect(result.shareCountToday).toBe(0); - expect(result.allowed).toBe(true); - }); - - it('does not count clips from yesterday', async () => { - const { groupId, userId } = await setupGroup(); - const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000); - insertClip(groupId, userId, yesterday); - const result = checkDailyShareLimit(userId, groupId, 1); - expect(result.shareCountToday).toBe(0); - expect(result.allowed).toBe(true); - }); -}); - -describe('enforceDailyShareLimit', () => { - it('returns null response when allowed', async () => { - const { groupId, userId } = await setupGroup(); - const { response } = enforceDailyShareLimit(userId, groupId, 5); - expect(response).toBeNull(); - }); - - it('returns 429 response when limit reached', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - const { response } = enforceDailyShareLimit(userId, groupId, 1); - expect(response).not.toBeNull(); - expect(response!.status).toBe(429); - }); -}); - -describe('formatResetTime / formatRelativeTime', () => { - it('formatResetTime returns a non-empty string', () => { - expect(formatResetTime()).toMatch(/(hour|minute|second)/); - }); - - it('formatRelativeTime formats hours and minutes', () => { - expect(formatRelativeTime(2 * 3600_000 + 30 * 60_000)).toMatch(/2h 30m|2 hours/); - }); - - it('formatRelativeTime formats minutes only when < 1h', () => { - expect(formatRelativeTime(15 * 60_000)).toContain('15'); - }); -}); - -describe('checkSharePacing', () => { - it('returns mode "off" when sharePacingMode is off', async () => { - const { groupId, userId } = await setupGroup(); - const result = checkSharePacing(userId, groupId, { - sharePacingMode: 'off', - dailyShareLimit: null, - shareBurst: 2, - shareCooldownMinutes: 120, - cloutEnabled: false - }); - expect(result.mode).toBe('off'); - }); - - it('returns mode "daily_cap" with allowed when under limit', async () => { - const { groupId, userId } = await setupGroup(); - const result = checkSharePacing(userId, groupId, { - sharePacingMode: 'daily_cap', - dailyShareLimit: 5, - shareBurst: 2, - shareCooldownMinutes: 120, - cloutEnabled: false - }); - expect(result.mode).toBe('daily_cap'); - if (result.mode === 'daily_cap') { - expect(result.response).toBeNull(); - expect(result.limitCheck.allowed).toBe(true); - } - }); - - it('returns 429 in daily_cap mode when at limit', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - const result = checkSharePacing(userId, groupId, { - sharePacingMode: 'daily_cap', - dailyShareLimit: 2, - shareBurst: 2, - shareCooldownMinutes: 120, - cloutEnabled: false - }); - expect(result.mode).toBe('daily_cap'); - if (result.mode === 'daily_cap') { - expect(result.response).not.toBeNull(); - expect(result.response!.status).toBe(429); - } - }); -}); diff --git a/src/lib/server/clout.ts b/src/lib/server/clout.ts deleted file mode 100644 index ed37c92..0000000 --- a/src/lib/server/clout.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { db } from '$lib/server/db'; -import { clips, reactions, favorites, comments, watched, users } from '$lib/server/db/schema'; -import { eq, and, ne, sql, desc, isNull } from 'drizzle-orm'; -import { createLogger } from '$lib/server/logger'; - -const log = createLogger('clout'); - -const WINDOW_SIZE = 10; -const GROUP_WATCH_THRESHOLD = 0.75; -const RANK_DOWN_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000; - -export const TIERS = { - iconic: { - name: 'Iconic', - minScore: 1.0, - cooldownMultiplier: 0.5, - burst: 5, - queueLimit: null, // uncapped - icon: '/icons/clout/iconic.png' - }, - viral: { - name: 'Viral', - minScore: 0.7, - cooldownMultiplier: 1.0, - burst: 3, - queueLimit: null, - icon: '/icons/clout/viral.png' - }, - rising: { - name: 'Rising', - minScore: 0.4, - cooldownMultiplier: 2.0, - burst: 2, - queueLimit: 10, - icon: '/icons/clout/rising.png' - }, - fresh: { - name: 'Fresh', - minScore: 0, - cooldownMultiplier: 3.0, - burst: 1, - queueLimit: 6, - icon: '/icons/clout/fresh.png' - } -} as const; - -export type TierKey = keyof typeof TIERS; - -export interface TierConfig { - name: string; - minScore: number; - cooldownMultiplier: number; - burst: number; - queueLimit: number | null; - icon: string; -} - -export interface ClipBreakdown { - clipId: string; - score: number; - title: string | null; - platform: string; - originalUrl: string; - thumbnailPath: string | null; -} - -export interface CloutResult { - score: number; - tier: TierKey; - tierConfig: TierConfig; - breakdown: ClipBreakdown[]; - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; - tierActuallyChanged: boolean; -} - -/** - * Get the clout tier for a given score. - */ -export function getCloutTier(score: number): { key: TierKey; config: TierConfig } { - if (score >= TIERS.iconic.minScore) return { key: 'iconic', config: TIERS.iconic }; - if (score >= TIERS.viral.minScore) return { key: 'viral', config: TIERS.viral }; - if (score >= TIERS.rising.minScore) return { key: 'rising', config: TIERS.rising }; - return { key: 'fresh', config: TIERS.fresh }; -} - -const TIER_ORDER: TierKey[] = ['fresh', 'rising', 'viral', 'iconic']; - -/** - * Get the next tier above the given one, or null if already at top. - */ -export function getNextTier(currentTier: TierKey): { key: TierKey; config: TierConfig } | null { - const idx = TIER_ORDER.indexOf(currentTier); - if (idx < 0 || idx >= TIER_ORDER.length - 1) return null; - const nextKey = TIER_ORDER[idx + 1]; - return { key: nextKey, config: TIERS[nextKey] }; -} - -/** - * Determine the effective tier after applying rank-down protection. - * Rank-ups apply immediately. Rank-downs only apply if the user has held - * their current tier for at least 4 days. - */ -export function getEffectiveTier( - computedTier: TierKey, - storedTier: TierKey | null, - tierChangedAt: Date | null -): { effectiveTier: TierKey; tierActuallyChanged: boolean } { - if (!storedTier) { - return { effectiveTier: computedTier, tierActuallyChanged: false }; - } - - const computedIdx = TIER_ORDER.indexOf(computedTier); - const storedIdx = TIER_ORDER.indexOf(storedTier); - - if (computedIdx >= storedIdx) { - // Rank up (or same) — apply immediately - return { - effectiveTier: computedTier, - tierActuallyChanged: computedTier !== storedTier - }; - } - - // Rank down — check 4-day stability requirement - const now = Date.now(); - const changedAt = tierChangedAt?.getTime() ?? 0; - - if (now - changedAt >= RANK_DOWN_COOLDOWN_MS) { - return { effectiveTier: computedTier, tierActuallyChanged: true }; - } - - // Still within cooldown — keep the higher tier - return { effectiveTier: storedTier, tierActuallyChanged: false }; -} - -/** - * Compute clout score for a user in a group. - * - * Scoring per clip (0 / 1 / 2): - * 0 = no reactions or favorites from others - * 1 = at least 1 reaction or favorite, but no comments from others - * 2 = at least 1 reaction/fav AND at least 1 comment from others - * - * Only clips watched by ≥75% of other group members are eligible. - * Returns the rolling average of the last 10 eligible clips. - * Users with <10 eligible clips default to Rising tier. - * - * Rank-down protection: rank-ups apply immediately, but rank-downs only - * take effect if the user has held their current tier for ≥4 days. - */ -export function getCloutScore( - userId: string, - groupId: string, - baseCooldownMinutes: number, - storedTier: TierKey | null = null, - tierChangedAt: Date | null = null -): CloutResult { - // Count active group members (not removed) - const [{ count: memberCount }] = db - .select({ count: sql`count(*)` }) - .from(users) - .where(and(eq(users.groupId, groupId), isNull(users.removedAt))) - .all(); - - // Get recent ready clips — fetch more than WINDOW_SIZE since some may be filtered out - const candidateClips = db - .select({ - id: clips.id, - title: clips.title, - platform: clips.platform, - originalUrl: clips.originalUrl, - thumbnailPath: clips.thumbnailPath - }) - .from(clips) - .where(and(eq(clips.addedBy, userId), eq(clips.groupId, groupId), eq(clips.status, 'ready'))) - .orderBy(desc(clips.createdAt)) - .limit(WINDOW_SIZE * 3) // fetch extra to account for watch threshold filtering - .all(); - - // Filter to clips watched by ≥75% of other group members - const otherMembers = memberCount - 1; - const eligibleClips = - otherMembers > 0 - ? candidateClips.filter((clip) => { - const [{ count: watchCount }] = db - .select({ count: sql`count(*)` }) - .from(watched) - .where(and(eq(watched.clipId, clip.id), ne(watched.userId, userId))) - .all(); - return watchCount / otherMembers >= GROUP_WATCH_THRESHOLD; - }) - : candidateClips; - - // Take only the most recent WINDOW_SIZE eligible clips - const windowClips = eligibleClips.slice(0, WINDOW_SIZE); - - // Not enough eligible clips — default to Rising - if (windowClips.length < WINDOW_SIZE) { - const { key, config } = getCloutTier(TIERS.rising.minScore); - const cooldownMinutes = Math.round(baseCooldownMinutes * config.cooldownMultiplier); - return { - score: -1, // sentinel: not enough data - tier: key, - tierConfig: config, - breakdown: [], - cooldownMinutes, - burstSize: config.burst, - queueLimit: config.queueLimit, - tierActuallyChanged: false - }; - } - - const breakdown: ClipBreakdown[] = []; - - for (const clip of windowClips) { - const clipId = clip.id; - // Count reactions from others - const [reactionResult] = db - .select({ count: sql`count(*)` }) - .from(reactions) - .where(and(eq(reactions.clipId, clipId), ne(reactions.userId, userId))) - .all(); - - // Count favorites from others - const [favResult] = db - .select({ count: sql`count(*)` }) - .from(favorites) - .where(and(eq(favorites.clipId, clipId), ne(favorites.userId, userId))) - .all(); - - // Count comments from others - const [commentResult] = db - .select({ count: sql`count(*)` }) - .from(comments) - .where(and(eq(comments.clipId, clipId), ne(comments.userId, userId))) - .all(); - - const hasReactionOrFav = (reactionResult?.count ?? 0) + (favResult?.count ?? 0) > 0; - const hasComment = (commentResult?.count ?? 0) > 0; - - let score: number; - if (hasReactionOrFav && hasComment) { - score = 2; - } else if (hasReactionOrFav) { - score = 1; - } else { - score = 0; - } - - breakdown.push({ - clipId, - score, - title: clip.title, - platform: clip.platform, - originalUrl: clip.originalUrl, - thumbnailPath: clip.thumbnailPath - }); - } - - const totalScore = breakdown.reduce((sum, b) => sum + b.score, 0); - const averageScore = totalScore / breakdown.length; - // Round to 1 decimal place - const score = Math.round(averageScore * 10) / 10; - - const { key: computedTier } = getCloutTier(score); - - // Apply rank-down protection - const { effectiveTier, tierActuallyChanged } = getEffectiveTier( - computedTier, - storedTier, - tierChangedAt - ); - const effectiveConfig = TIERS[effectiveTier]; - const cooldownMinutes = Math.round(baseCooldownMinutes * effectiveConfig.cooldownMultiplier); - - log.info( - { - userId, - score, - computedTier, - effectiveTier, - cooldownMinutes, - burst: effectiveConfig.burst, - tierActuallyChanged - }, - 'clout score computed' - ); - - return { - score, - tier: effectiveTier, - tierConfig: effectiveConfig, - breakdown, - cooldownMinutes, - burstSize: effectiveConfig.burst, - queueLimit: effectiveConfig.queueLimit, - tierActuallyChanged - }; -} diff --git a/src/lib/server/share-limit.ts b/src/lib/server/share-limit.ts deleted file mode 100644 index d2762fe..0000000 --- a/src/lib/server/share-limit.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { clips } from '$lib/server/db/schema'; -import { eq, and, gte, sql } from 'drizzle-orm'; -import { checkBurstAvailable } from '$lib/server/queue'; -import { getCloutScore } from '$lib/server/clout'; -import type { TierKey } from '$lib/server/clout'; - -/** - * Calculate the start of "today" in the user's timezone as a UTC Date. - * Falls back to UTC midnight if no timezone is provided or invalid. - */ -export function getTodayStart(tz?: string | null): Date { - const now = new Date(); - if (tz) { - try { - // Format current time in user's timezone to extract local date parts - const formatter = new Intl.DateTimeFormat('en-CA', { - timeZone: tz, - year: 'numeric', - month: '2-digit', - day: '2-digit' - }); - const parts = formatter.formatToParts(now); - const year = parts.find((p) => p.type === 'year')!.value; - const month = parts.find((p) => p.type === 'month')!.value; - const day = parts.find((p) => p.type === 'day')!.value; - - // Build midnight in that timezone, then get the UTC equivalent - // Create a date string and use the timezone to find the offset - const midnightLocal = new Date(`${year}-${month}-${day}T00:00:00`); - const utcRef = new Date(midnightLocal.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzRef = new Date(midnightLocal.toLocaleString('en-US', { timeZone: tz })); - const offsetMs = utcRef.getTime() - tzRef.getTime(); - - return new Date(midnightLocal.getTime() + offsetMs); - } catch { - // Invalid timezone — fall through to UTC - } - } - // Fallback: UTC midnight - const utcMidnight = new Date(now); - utcMidnight.setUTCHours(0, 0, 0, 0); - return utcMidnight; -} - -/** - * Check if a user has hit the daily share limit. - * Returns { allowed, shareCountToday, dailyShareLimit }. - */ -export function checkDailyShareLimit( - userId: string, - groupId: string, - dailyShareLimit: number | null, - tz?: string | null -): { allowed: boolean; shareCountToday: number; dailyShareLimit: number | null } { - if (dailyShareLimit === null) { - return { allowed: true, shareCountToday: 0, dailyShareLimit: null }; - } - - const todayStart = getTodayStart(tz); - - const [result] = db - .select({ count: sql`count(*)` }) - .from(clips) - .where( - and(eq(clips.addedBy, userId), eq(clips.groupId, groupId), gte(clips.createdAt, todayStart)) - ) - .all(); - - const shareCountToday = result?.count ?? 0; - return { - allowed: shareCountToday < dailyShareLimit, - shareCountToday, - dailyShareLimit - }; -} - -export type ShareLimitResult = ReturnType; - -/** - * Human-readable time until midnight reset in the user's timezone. - */ -export function formatResetTime(tz?: string | null): string { - const todayStart = getTodayStart(tz); - const tomorrowStart = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000); - const ms = Math.max(0, tomorrowStart.getTime() - Date.now()); - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'}`; - if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'}`; - return `${seconds} second${seconds === 1 ? '' : 's'}`; -} - -/** - * If the daily share limit has been reached, return a 429 JSON response. - * Otherwise return null (caller should proceed). - * Also returns the limitCheck data for use in success responses. - */ -export function enforceDailyShareLimit( - userId: string, - groupId: string, - dailyShareLimit: number | null, - tz?: string | null -): { response: Response | null; limitCheck: ShareLimitResult } { - const limitCheck = checkDailyShareLimit(userId, groupId, dailyShareLimit, tz); - if (limitCheck.allowed) return { response: null, limitCheck }; - - const resetsIn = formatResetTime(tz); - return { - response: json( - { - error: `Daily share limit reached (${limitCheck.shareCountToday}/${limitCheck.dailyShareLimit}). Resets in ${resetsIn}.`, - shareCountToday: limitCheck.shareCountToday, - dailyShareLimit: limitCheck.dailyShareLimit, - resetsIn, - limitReached: true - }, - { status: 429 } - ), - limitCheck - }; -} - -/** - * Human-readable relative time string. - */ -export function formatRelativeTime(ms: number): string { - const totalMinutes = Math.ceil(ms / 60_000); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`; - if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'}`; - return `${minutes} minute${minutes === 1 ? '' : 's'}`; -} - -export type SharePacingResult = - | { mode: 'off' } - | { mode: 'daily_cap'; response: Response | null; limitCheck: ShareLimitResult } - | { - mode: 'queue'; - queued: boolean; - scheduledAt?: Date; - nextSlotAt?: Date; - queueFull?: boolean; - clout?: { - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; - tier: string; - tierName: string; - }; - }; - -interface GroupPacingConfig { - sharePacingMode: string; - dailyShareLimit: number | null; - shareBurst: number; - shareCooldownMinutes: number; - cloutEnabled: boolean; -} - -interface UserTierHistory { - cloutTier: string | null; - cloutTierChangedAt: Date | null; -} - -/** - * Unified share pacing check. Dispatches based on group's pacing mode. - * Queue mode never rejects — it returns queued: true with schedule info. - */ -export function checkSharePacing( - userId: string, - groupId: string, - group: GroupPacingConfig, - tz?: string | null, - userTierHistory?: UserTierHistory -): SharePacingResult { - switch (group.sharePacingMode) { - case 'daily_cap': { - const limitCheck = checkDailyShareLimit(userId, groupId, group.dailyShareLimit, tz); - if (limitCheck.allowed) { - return { mode: 'daily_cap', response: null, limitCheck }; - } - const resetsIn = formatResetTime(tz); - return { - mode: 'daily_cap', - response: json( - { - error: `Daily share limit reached (${limitCheck.shareCountToday}/${limitCheck.dailyShareLimit}). Resets in ${resetsIn}.`, - shareCountToday: limitCheck.shareCountToday, - dailyShareLimit: limitCheck.dailyShareLimit, - resetsIn, - limitReached: true - }, - { status: 429 } - ), - limitCheck - }; - } - case 'queue': { - if (group.cloutEnabled) { - const clout = getCloutScore( - userId, - groupId, - group.shareCooldownMinutes, - (userTierHistory?.cloutTier as TierKey) ?? null, - userTierHistory?.cloutTierChangedAt ?? null - ); - const burst = checkBurstAvailable(userId, groupId, clout.burstSize, clout.cooldownMinutes); - return { - mode: 'queue', - queued: !burst.available, - nextSlotAt: burst.nextSlotAt ?? undefined, - clout: { - cooldownMinutes: clout.cooldownMinutes, - burstSize: clout.burstSize, - queueLimit: clout.queueLimit, - tier: clout.tier, - tierName: clout.tierConfig.name - } - }; - } - // Clout disabled — use base group settings directly - const burst = checkBurstAvailable( - userId, - groupId, - group.shareBurst, - group.shareCooldownMinutes - ); - return { - mode: 'queue', - queued: !burst.available, - nextSlotAt: burst.nextSlotAt ?? undefined - }; - } - default: - return { mode: 'off' }; - } -} diff --git a/src/lib/stores/cloutChange.ts b/src/lib/stores/cloutChange.ts deleted file mode 100644 index daed73b..0000000 --- a/src/lib/stores/cloutChange.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { writable } from 'svelte/store'; - -export interface CloutChange { - previousTier: string; - newTier: string; - previousTierName: string; - newTierName: string; - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; -} - -export const cloutChange = writable(null); diff --git a/src/lib/stores/credits.ts b/src/lib/stores/credits.ts new file mode 100644 index 0000000..7f61661 --- /dev/null +++ b/src/lib/stores/credits.ts @@ -0,0 +1,24 @@ +import { writable, derived } from 'svelte/store'; +import { queueCount } from '$lib/stores/queue'; + +/** Current post-credit balance for the logged-in user. */ +export const credits = writable(0); + +/** The group's max credits (cap). Seeded from page.data on mount. */ +export const reciprocityCap = writable(5); + +/** + * The soft feed gate. Active when the user has earned the max credits AND has clips + * waiting in their queue — they must post one to keep scrolling. With an empty queue + * the gate never fires (credits simply stop accruing past the cap), so a user is never + * trapped with nothing to post. + */ +export const gateActive = derived( + [credits, reciprocityCap, queueCount], + ([$credits, $cap, $queue]) => $credits >= $cap && $queue > 0 +); + +/** Update the balance from an API response that reports `credits`. */ +export function setCredits(value: number | undefined): void { + if (typeof value === 'number') credits.set(value); +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index eafe676..e982dd3 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -11,12 +11,11 @@ startPolling, stopPolling } from '$lib/stores/notifications'; - import { queueCount } from '$lib/stores/queue'; + import { queueCount, fetchQueueCount } from '$lib/stores/queue'; + import { credits, reciprocityCap } from '$lib/stores/credits'; import { globalMuted } from '$lib/stores/mute'; import { initAudioContext } from '$lib/audio/normalizer'; import { fetchGroupMembers } from '$lib/stores/members'; - import { cloutChange } from '$lib/stores/cloutChange'; - import { addToast } from '$lib/stores/toasts'; import QueueSheet from '$lib/components/QueueSheet.svelte'; import AddVideoModal from '$lib/components/AddVideoModal.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; @@ -39,54 +38,9 @@ return ''; }); - const TIER_NAMES: Record = { - fresh: 'Fresh', - rising: 'Rising', - viral: 'Viral', - iconic: 'Iconic' - }; - - const TIER_ORDER = ['fresh', 'rising', 'viral', 'iconic']; - - async function checkCloutTier() { - try { - const res = await fetch('/api/clout'); - if (!res.ok) return; - const data = await res.json(); - if (!data.enabled) return; - - if (data.tierChanged && data.lastTier) { - const isRankUp = TIER_ORDER.indexOf(data.tier) > TIER_ORDER.indexOf(data.lastTier); - const changeData = { - previousTier: data.lastTier, - newTier: data.tier, - previousTierName: TIER_NAMES[data.lastTier] ?? data.lastTier, - newTierName: data.tierName, - cooldownMinutes: data.cooldownMinutes, - burstSize: data.burstSize, - queueLimit: data.queueLimit ?? null - }; - - addToast({ - type: 'rank_change', - message: isRankUp ? 'You ranked up!' : 'Your rank changed', - rankIcon: data.icon, - rankTierName: data.tierName, - onTap: () => cloutChange.set(changeData) - }); - - // Acknowledge so it won't show again on other devices - fetch('/api/clout', { method: 'POST' }).catch(() => {}); - } - } catch { - // silently fail - } - } - onMount(() => { startPolling(); fetchGroupMembers(); - checkCloutTier(); // Measure actual bottom nav height and expose as CSS variable. // This adapts to the real safe-area insets on each device instead of @@ -109,6 +63,11 @@ globalMuted.set(user.mutedByDefault ?? true); } + // Seed reciprocity credit state for the feed gate + if (user) credits.set(user.postCredits ?? 0); + if (page.data?.group) reciprocityCap.set(page.data.group.reciprocityCap ?? 5); + fetchQueueCount(); + // Initialize AudioContext on first user interaction (for volume normalization) function handleFirstInteraction() { initAudioContext(); diff --git a/src/routes/(app)/me/+page.svelte b/src/routes/(app)/me/+page.svelte index 558eed4..44d95f6 100644 --- a/src/routes/(app)/me/+page.svelte +++ b/src/routes/(app)/me/+page.svelte @@ -17,7 +17,8 @@ import HeartIcon from 'phosphor-svelte/lib/HeartIcon'; import UploadSimpleIcon from 'phosphor-svelte/lib/UploadSimpleIcon'; import CameraIcon from 'phosphor-svelte/lib/CameraIcon'; - import { cloutChange } from '$lib/stores/cloutChange'; + import CreditDots from '$lib/components/CreditDots.svelte'; + import { credits, reciprocityCap } from '$lib/stores/credits'; const user = $derived(page.data.user); const group = $derived(page.data.group); @@ -26,15 +27,6 @@ const gifEnabled = $derived(!!page.data.gifEnabled); let stats = $state<{ uploads: number; saves: number; minutesWatched: number } | null>(null); - let clout = $state<{ - enabled: boolean; - tier?: string; - tierName?: string; - icon?: string; - cooldownMinutes?: number; - burstSize?: number; - queueLimit?: number | null; - } | null>(null); async function loadStats() { try { @@ -45,15 +37,6 @@ } } - async function loadClout() { - try { - const res = await fetch('/api/clout'); - if (res.ok) clout = await res.json(); - } catch { - /* non-critical */ - } - } - function formatWatchTime(minutes: number | null | undefined): string { if (minutes === null || minutes === undefined) return '--'; if (minutes < 1) return '<1m'; @@ -61,19 +44,6 @@ return `${(minutes / 60).toFixed(1)}h`; } - function showCloutModal() { - if (!clout?.enabled || !clout.tier || !clout.tierName) return; - cloutChange.set({ - previousTier: clout.tier, - newTier: clout.tier, - previousTierName: clout.tierName, - newTierName: clout.tierName, - cooldownMinutes: clout.cooldownMinutes ?? 0, - burstSize: clout.burstSize ?? 1, - queueLimit: clout.queueLimit ?? null - }); - } - // Avatar state let avatarCropImage = $state(null); let avatarOverride = $state(undefined); @@ -184,7 +154,6 @@ onMount(() => { loadFaves(); loadStats(); - loadClout(); }); @@ -217,11 +186,9 @@ {/if}
{user?.username} - {#if clout?.enabled && clout.tier && clout.icon} - - {/if} +
+
+
@@ -380,22 +347,10 @@ color: var(--text-primary); letter-spacing: -0.02em; } - .rank-badge { + .credits-row { display: flex; - align-items: center; justify-content: center; - padding: 0; - background: none; - border: none; - cursor: pointer; - } - .rank-badge:active { - transform: scale(0.93); - } - .rank-icon { - width: 22px; - height: 22px; - object-fit: contain; + margin-top: var(--space-sm); } .stats-row { display: flex; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 7759300..585d5f3 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -37,7 +37,7 @@ import DownloadProviderManager from '$lib/components/settings/DownloadProviderManager.svelte'; import PlatformFilter from '$lib/components/settings/PlatformFilter.svelte'; import ShortcutManager from '$lib/components/settings/ShortcutManager.svelte'; - import SharePacingPicker from '$lib/components/settings/SharePacingPicker.svelte'; + import ReciprocityPicker from '$lib/components/settings/ReciprocityPicker.svelte'; import GettingStartedChecklist from '$lib/components/settings/GettingStartedChecklist.svelte'; import SkippedClips from '$lib/components/settings/SkippedClips.svelte'; import Toggle from '$lib/components/settings/Toggle.svelte'; @@ -352,12 +352,10 @@

Share Pacing

-
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 07f72c6..fca128f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,6 @@ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import InstallBanner from '$lib/components/InstallBanner.svelte'; import SwUpdateToast from '$lib/components/SwUpdateToast.svelte'; - import CloutChangeModal from '$lib/components/CloutChangeModal.svelte'; import { isStandalone, detectStandaloneMode, @@ -116,7 +115,6 @@ -
diff --git a/src/routes/api/__tests__/clout.test.ts b/src/routes/api/__tests__/clout.test.ts deleted file mode 100644 index 6e6306b..0000000 --- a/src/routes/api/__tests__/clout.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { seed } from '../../../../tests/helpers/seed'; -import { createMockEvent } from '../../../../tests/helpers/request'; - -vi.mock('$lib/server/push', () => ({ - sendNotification: vi.fn(async () => {}), - notifyNewClip: vi.fn(async () => {}) -})); -vi.mock('$lib/server/scheduler', () => ({ startScheduler: vi.fn() })); -vi.mock('$lib/server/db', async () => { - const { createTestDb } = await import('../../../../tests/helpers/db'); - return createTestDb(); -}); - -const { db } = await import('$lib/server/db'); -const data = await seed(db as any); - -const cloutMod = await import('../clout/+server'); - -describe('GET /api/clout', () => { - it('returns 401 without auth', async () => { - const event = createMockEvent({ method: 'GET', path: '/api/clout' }); - const res = await cloutMod.GET(event); - expect(res.status).toBe(401); - }); - - it('returns enabled:false when not in queue mode', async () => { - const event = createMockEvent({ - method: 'GET', - path: '/api/clout', - user: data.host, - group: { ...data.group, sharePacingMode: 'off' } - }); - const res = await cloutMod.GET(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.enabled).toBe(false); - }); - - it('returns enabled:true with score+tier when in queue mode + clout enabled', async () => { - const event = createMockEvent({ - method: 'GET', - path: '/api/clout', - user: data.host, - group: { ...data.group, sharePacingMode: 'queue', cloutEnabled: true } - }); - const res = await cloutMod.GET(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.enabled).toBe(true); - expect(body.tier).toBeDefined(); - expect(body.tierName).toBeDefined(); - }); -}); - -describe('POST /api/clout', () => { - it('returns 401 without auth', async () => { - const event = createMockEvent({ method: 'POST', path: '/api/clout' }); - const res = await cloutMod.POST(event); - expect(res.status).toBe(401); - }); - - it('returns ok:true and updates user tier ack', async () => { - const event = createMockEvent({ - method: 'POST', - path: '/api/clout', - user: data.host, - group: { ...data.group, sharePacingMode: 'queue' } - }); - const res = await cloutMod.POST(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.ok).toBe(true); - }); -}); diff --git a/src/routes/api/clout/+server.ts b/src/routes/api/clout/+server.ts deleted file mode 100644 index 30824a4..0000000 --- a/src/routes/api/clout/+server.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { withAuth } from '$lib/server/api-utils'; -import { getCloutScore, getNextTier, TIERS } from '$lib/server/clout'; -import type { TierKey } from '$lib/server/clout'; -import { db } from '$lib/server/db'; -import { users } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { dev } from '$app/environment'; - -export const GET: RequestHandler = withAuth(async (event, { user, group }) => { - if (group.sharePacingMode !== 'queue' || !group.cloutEnabled) { - return json({ enabled: false }); - } - - const result = getCloutScore( - user.id, - user.groupId, - group.shareCooldownMinutes, - (user.cloutTier as TierKey) ?? null, - user.cloutTierChangedAt ?? null - ); - - // DEV ONLY: ?tier=rising to force a specific tier for testing - if (dev) { - const forceTier = event.url.searchParams.get('tier'); - if (forceTier && forceTier in TIERS) { - const config = TIERS[forceTier as keyof typeof TIERS]; - result.tier = forceTier as keyof typeof TIERS; - result.tierConfig = config; - result.cooldownMinutes = Math.round(group.shareCooldownMinutes * config.cooldownMultiplier); - result.burstSize = config.burst; - result.queueLimit = config.queueLimit; - } - } - - const nextTier = getNextTier(result.tier); - - // Determine if a tier change should be reported - const lastAckedTier = user.cloutTier; - let tierChanged = false; - - if (!lastAckedTier) { - // First time — seed the tier silently (no notification on first load) - db.update(users) - .set({ cloutTier: result.tier, cloutTierChangedAt: new Date() }) - .where(eq(users.id, user.id)) - .run(); - } else if (result.tierActuallyChanged) { - tierChanged = true; - // Persist the new effective tier and reset the changed-at timestamp - db.update(users) - .set({ cloutTier: result.tier, cloutTierChangedAt: new Date() }) - .where(eq(users.id, user.id)) - .run(); - } - - return json({ - enabled: true, - score: result.score, - tier: result.tier, - tierName: result.tierConfig.name, - cooldownMinutes: result.cooldownMinutes, - burstSize: result.burstSize, - queueLimit: result.queueLimit, - icon: result.tierConfig.icon, - breakdown: result.breakdown.map((b) => ({ clipId: b.clipId, score: b.score })), - nextTier: nextTier - ? { - tier: nextTier.key, - tierName: nextTier.config.name, - minScore: nextTier.config.minScore, - cooldownMultiplier: nextTier.config.cooldownMultiplier, - burst: nextTier.config.burst, - queueLimit: nextTier.config.queueLimit, - icon: nextTier.config.icon - } - : null, - baseCooldownMinutes: group.shareCooldownMinutes, - lastTier: lastAckedTier ?? null, - tierChanged - }); -}); - -/** Acknowledge that the tier change was seen. Updates the acked tier so - * future checks compare against the tier the user was notified about. */ -export const POST: RequestHandler = withAuth(async (_event, { user, group }) => { - // Recompute current tier so we store the accurate value - const result = getCloutScore( - user.id, - user.groupId, - group.shareCooldownMinutes, - (user.cloutTier as TierKey) ?? null, - user.cloutTierChangedAt ?? null - ); - db.update(users) - .set({ - cloutTier: result.tier, - cloutChangeShownAt: new Date(), - cloutTierChangedAt: new Date() - }) - .where(eq(users.id, user.id)) - .run(); - return json({ ok: true }); -}); diff --git a/src/routes/api/group/daily-share-limit/+server.ts b/src/routes/api/group/daily-share-limit/+server.ts deleted file mode 100644 index b866665..0000000 --- a/src/routes/api/group/daily-share-limit/+server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { db } from '$lib/server/db'; -import { groups } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { withHost, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; - -export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { - const body = await parseBody<{ dailyShareLimit?: number | null }>(request); - if (isResponse(body)) return body; - - const { dailyShareLimit } = body; - - if (dailyShareLimit !== null && dailyShareLimit !== undefined) { - if (!Number.isInteger(dailyShareLimit) || dailyShareLimit < 1) { - return badRequest('Daily share limit must be a positive integer or null'); - } - } - - await db - .update(groups) - .set({ dailyShareLimit: dailyShareLimit ?? null }) - .where(eq(groups.id, group.id)); - - return json({ dailyShareLimit }); -}); diff --git a/src/routes/api/group/share-pacing/+server.ts b/src/routes/api/group/share-pacing/+server.ts deleted file mode 100644 index 5b3e005..0000000 --- a/src/routes/api/group/share-pacing/+server.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { db } from '$lib/server/db'; -import { groups } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { withHost, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; -import { flushQueue } from '$lib/server/queue'; - -const VALID_MODES = ['off', 'daily_cap', 'queue'] as const; -const VALID_COOLDOWNS = [30, 60, 120, 240, 360] as const; - -interface PacingBody { - sharePacingMode?: string; - shareBurst?: number; - shareCooldownMinutes?: number; - dailyShareLimit?: number | null; - cloutEnabled?: boolean; -} - -function validatePacingBody(body: PacingBody): string | null { - const { sharePacingMode, shareBurst, shareCooldownMinutes, dailyShareLimit } = body; - if ( - sharePacingMode !== undefined && - !(VALID_MODES as readonly string[]).includes(sharePacingMode) - ) { - return 'Invalid pacing mode. Must be off, daily_cap, or queue.'; - } - if ( - shareBurst !== undefined && - (!Number.isInteger(shareBurst) || shareBurst < 1 || shareBurst > 10) - ) { - return 'Burst must be an integer between 1 and 10.'; - } - if ( - shareCooldownMinutes !== undefined && - !(VALID_COOLDOWNS as readonly number[]).includes(shareCooldownMinutes) - ) { - return 'Cooldown must be 30, 60, 120, 240, or 360 minutes.'; - } - if ( - dailyShareLimit !== undefined && - dailyShareLimit !== null && - (!Number.isInteger(dailyShareLimit) || dailyShareLimit < 1) - ) { - return 'Daily share limit must be a positive integer or null.'; - } - return null; -} - -export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { - const body = await parseBody(request); - if (isResponse(body)) return body; - - const error = validatePacingBody(body); - if (error) return badRequest(error); - - const { sharePacingMode, shareBurst, shareCooldownMinutes, dailyShareLimit, cloutEnabled } = body; - - const updates: Record = {}; - if (sharePacingMode !== undefined) updates.sharePacingMode = sharePacingMode; - if (shareBurst !== undefined) updates.shareBurst = shareBurst; - if (shareCooldownMinutes !== undefined) updates.shareCooldownMinutes = shareCooldownMinutes; - if (dailyShareLimit !== undefined) updates.dailyShareLimit = dailyShareLimit ?? null; - if (cloutEnabled !== undefined) updates.cloutEnabled = cloutEnabled; - - if (Object.keys(updates).length === 0) return badRequest('No fields to update.'); - - // If switching away from queue mode, flush all queued clips - if (group.sharePacingMode === 'queue' && (sharePacingMode ?? 'queue') !== 'queue') { - await flushQueue(group.id); - } - - db.update(groups).set(updates).where(eq(groups.id, group.id)).run(); - - return json({ - sharePacingMode: (updates.sharePacingMode as string) ?? group.sharePacingMode, - shareBurst: (updates.shareBurst as number) ?? group.shareBurst, - shareCooldownMinutes: (updates.shareCooldownMinutes as number) ?? group.shareCooldownMinutes, - dailyShareLimit: - dailyShareLimit !== undefined ? (dailyShareLimit ?? null) : group.dailyShareLimit, - cloutEnabled: cloutEnabled !== undefined ? cloutEnabled : group.cloutEnabled - }); -}); From d3eb55ea91313bb2fb57bd9737f17ae9777245c2 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:53:31 -0500 Subject: [PATCH 07/11] feat(feed): soft reciprocity gate on forward scroll markClipWatched syncs the returned credit balance into the store. The feed blocks forward advance (scrollToIndex, wheel, keyboard, native momentum via a scroll-lock class) once the user is at the credit cap with a non-empty queue, opening the post sheet instead. An empty queue never gates. --- src/lib/feed.ts | 12 ++++++++++- src/routes/(app)/+page.svelte | 40 ++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 189b71d..7ada4e0 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -1,6 +1,7 @@ import type { FeedClip } from '$lib/types'; import { fetchUnwatchedCount } from '$lib/stores/notifications'; import { clearPushNotifications } from '$lib/push'; +import { setCredits } from '$lib/stores/credits'; export type FeedFilter = 'all' | 'unwatched' | 'watched' | 'favorites' | 'uploads'; export type FeedSort = 'oldest' | 'round-robin' | 'best'; @@ -37,7 +38,16 @@ export async function fetchClips( } export async function markClipWatched(clipId: string): Promise { - await fetch(`/api/clips/${clipId}/watched`, { method: 'POST' }); + const res = await fetch(`/api/clips/${clipId}/watched`, { method: 'POST' }); + // Watching another member's clip may earn a reciprocity credit — sync the balance. + if (res.ok) { + try { + const data = await res.json(); + setCredits(data.credits); + } catch { + /* no body — ignore */ + } + } fetchUnwatchedCount(); clearPushNotifications(`new-clip-${clipId}`); } diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 390946b..3c859dd 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -24,6 +24,8 @@ import CatchUpModal from '$lib/components/CatchUpModal.svelte'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { anySheetOpen } from '$lib/stores/sheetOpen'; + import { gateActive } from '$lib/stores/credits'; + import { queueSheetOpen } from '$lib/stores/queueSheet'; import { get } from 'svelte/store'; import { onMount } from 'svelte'; import { page } from '$app/state'; @@ -110,7 +112,6 @@ const lastLegacyShareAt = $derived(page.data.user?.lastLegacyShareAt ?? null); const usedNewShareFlow = $derived(page.data.user?.usedNewShareFlow ?? false); const shortcutUrl = $derived(page.data.group?.shortcutUrl ?? null); - const isQueueMode = $derived(page.data.group?.sharePacingMode === 'queue'); let pushSupported = $state(false); let pushEnabled = $state(false); @@ -196,8 +197,34 @@ } } + // Reciprocity gate: once you've banked the max credits AND have clips waiting in your + // queue, you must post one before scrolling forward to the next clip. Backward scroll is + // always allowed; an empty queue never gates. + let gateShownForEpisode = $state(false); + + function openGate() { + gateShownForEpisode = true; + queueSheetOpen.set(true); + } + + // Re-arm the gate after the user posts (credits drop below cap) or empties the queue. + $effect(() => { + if (!$gateActive) gateShownForEpisode = false; + }); + + // Surface the post sheet the moment the gate engages, so the block is explained rather + // than just feeling stuck. Only auto-opens once per episode and never over another sheet. + $effect(() => { + if ($gateActive && !gateShownForEpisode && !get(anySheetOpen)) openGate(); + }); + function scrollToIndex(index: number) { if (!scrollContainer || index < 0) return; + // Forward advance while gated → open the post sheet instead of moving on. + if (index > activeIndex && get(gateActive)) { + openGate(); + return; + } const slots = scrollContainer.querySelectorAll('.reel-slot'); if (index >= slots.length) return; const slot = slots[index] as HTMLElement | undefined; @@ -915,11 +942,11 @@

All caught up

Drop a clip to kick things off

{:else} -
+
{#each clips as clip, i (clip.id)}
{#if Math.abs(i - activeIndex) <= renderWindow} @@ -1059,6 +1086,13 @@ overscroll-behavior-y: none; scrollbar-width: none; } + /* When the reciprocity gate is engaged, pin the feed so native momentum scrolling can't + slip past the current reel. The post sheet (opened by the gate) is the way forward. */ + .reel-scroll.scroll-locked { + overflow-y: hidden; + scroll-snap-type: none; + touch-action: none; + } .reel-scroll::-webkit-scrollbar { display: none; } From 78fc9875c1c31fd2087a049ec6eadf433044ed5f Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 12:59:48 -0500 Subject: [PATCH 08/11] feat(ui): multi-select queue sheet, credit-aware share window QueueSheet becomes a multi-select post surface (sort/reverse, lazy-load, tap-to-explain credits pill, Post N) using SvelteSet for selection state. AddVideo/AddVideoModal show the credit dots and a posts-now-vs-queued hint and sync the returned balance. The share landing page is simplified to posted/queued states. UploadStatus drops the daily-limit dots and timed copy. --- src/lib/components/AddVideo.svelte | 56 ++-- src/lib/components/AddVideoModal.svelte | 8 +- src/lib/components/QueueSheet.svelte | 392 +++++++++++++----------- src/lib/components/UploadStatus.svelte | 33 +- src/routes/share/+page.svelte | 352 +-------------------- 5 files changed, 272 insertions(+), 569 deletions(-) diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index be9ac9e..cf328f5 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -1,6 +1,7 @@ @@ -200,21 +173,31 @@
Your Queue -
- {#if items.length > 0} - - {/if} +
+ {#if items.length > 1} + + {/if} + {#if items.length > 0} + + {/if} +
{/snippet} - {#if clout} - - {/if} - -
+
{#if loading}
Loading... @@ -223,26 +206,25 @@
No clips queued - Clips that exceed your burst will appear here + Clips you add while out of credits wait here until you post them
{:else} -
    - {#each items as item, i (item.id)} -
  • +
      + {#each items as item (item.id)} + {@const isQueued = item.status === 'queued'} + {@const isSelected = selected.has(item.id)} +
    • {#if item.thumbnailPath} @@ -256,11 +238,12 @@
      {displayTitle(item)} - {#if item.status === 'failed'} - Failed + Download failed + {:else if item.status === 'downloading'} + Downloading… {:else} - Shares in ~{item.sharesIn} + Ready to post {/if}
      @@ -274,15 +257,31 @@
    • {/each}
    + {#if loadingMore} +
    Loading…
    + {/if} {/if}
+ + {#if selected.size > 0} +
+ +
+ {/if} +{#if showInfo} + (showInfo = false)} /> +{/if} + diff --git a/src/lib/components/UploadStatus.svelte b/src/lib/components/UploadStatus.svelte index 1fad92c..c658f85 100644 --- a/src/lib/components/UploadStatus.svelte +++ b/src/lib/components/UploadStatus.svelte @@ -6,7 +6,6 @@ import LightbulbIcon from 'phosphor-svelte/lib/LightbulbIcon'; import ScissorsIcon from 'phosphor-svelte/lib/ScissorsIcon'; import ClockIcon from 'phosphor-svelte/lib/ClockIcon'; - import ShareLimitDots from './ShareLimitDots.svelte'; const { phase, @@ -19,19 +18,13 @@ onsaveandview, ondismissnudge, ontrim, - onskiptrim, - shareCountToday, - dailyShareLimit, - queuedSharesIn + onskiptrim }: { phase: 'uploading' | 'done' | 'failed' | 'trim_prompt' | 'queued'; clipContentType: string; serverTitle: string | null; serverArtist: string | null; serverAlbumArt: string | null; - shareCountToday?: number; - dailyShareLimit?: number | null; - queuedSharesIn?: string; ondismiss: () => void; onretry: () => void; onsaveandview: () => void; @@ -43,7 +36,7 @@ function getStatusText(p: typeof phase, ct: string): string { if (p === 'uploading') return ct === 'music' ? 'Hang tight...' : 'Downloading video...'; if (p === 'trim_prompt') return 'Share the best part?'; - if (p === 'queued') return 'Queued!'; + if (p === 'queued') return 'Saved to your queue'; if (p === 'done') return 'Ready!'; return 'Download failed'; } @@ -126,25 +119,18 @@ > {:else if phase === 'queued'} - {#if queuedSharesIn} -

Your clip will share in ~{queuedSharesIn}

- {/if} - +

Post it from your queue when you're ready.

+ {:else if phase === 'done'} - {#if dailyShareLimit !== undefined && dailyShareLimit !== null && shareCountToday !== undefined} -
- -
- {/if} {#if $showShortcutNudge} @@ -388,13 +374,6 @@ text-overflow: ellipsis; } - .limit-dots-wrap { - margin-top: var(--space-md); - } - .limit-dots-wrap :global(.count-text) { - color: rgba(255, 255, 255, 0.5); - } - .primary-btn { margin-top: var(--space-xl); padding: 14px 32px; diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte index b24f949..af015a9 100644 --- a/src/routes/share/+page.svelte +++ b/src/routes/share/+page.svelte @@ -1,5 +1,4 @@
@@ -100,6 +123,22 @@
+
+ Daily post limit +
+ +
+
+

{description}

diff --git a/src/lib/server/__tests__/credits.test.ts b/src/lib/server/__tests__/credits.test.ts index 49b8e34..8cfc20d 100644 --- a/src/lib/server/__tests__/credits.test.ts +++ b/src/lib/server/__tests__/credits.test.ts @@ -8,11 +8,23 @@ vi.mock('$lib/server/db', async () => { }); const { db } = await import('$lib/server/db'); -const { getReciprocityConfig, getCredits, spendCredit, earnCredit, grantWatchCredit } = - await import('../credits'); +const { + getReciprocityConfig, + getCredits, + spendCredit, + earnCredit, + grantWatchCredit, + dailyPostsRemaining +} = await import('../credits'); function setup( - opts: { seed?: number; cap?: number; ratio?: number; startedAt?: Date | null } = {} + opts: { + seed?: number; + cap?: number; + ratio?: number; + startedAt?: Date | null; + dailyPostLimit?: number | null; + } = {} ) { const groupId = uuid(); const userId = uuid(); @@ -28,6 +40,7 @@ function setup( reciprocityCap: opts.cap ?? 5, reciprocityRatio: opts.ratio ?? 1, reciprocityStartedAt: opts.startedAt ?? null, + dailyPostLimit: opts.dailyPostLimit ?? null, createdAt: now }) .run(); @@ -89,11 +102,23 @@ function watch(clipId: string, userId: string) { describe('getReciprocityConfig', () => { it('returns the group config', () => { const { groupId } = setup({ seed: 3, cap: 8, ratio: 2 }); - expect(getReciprocityConfig(groupId)).toEqual({ seed: 3, cap: 8, ratio: 2, startedAt: null }); + expect(getReciprocityConfig(groupId)).toEqual({ + seed: 3, + cap: 8, + ratio: 2, + startedAt: null, + dailyPostLimit: null + }); }); it('falls back to 5/5/1 for an unknown group', () => { - expect(getReciprocityConfig('nope')).toEqual({ seed: 5, cap: 5, ratio: 1, startedAt: null }); + expect(getReciprocityConfig('nope')).toEqual({ + seed: 5, + cap: 5, + ratio: 1, + startedAt: null, + dailyPostLimit: null + }); }); }); @@ -199,3 +224,25 @@ describe('grantWatchCredit — launch cutoff (Path A)', () => { expect(grantWatchCredit(userId, groupId, AFTER)).toBe(1); }); }); + +describe('dailyPostsRemaining (daily cap)', () => { + it('returns null when the group has no daily cap', () => { + const { groupId, userId } = setup({ dailyPostLimit: null }); + expect(dailyPostsRemaining(userId, groupId)).toBeNull(); + }); + + it('counts the user’s recent posts against the cap', () => { + const { groupId, userId } = setup({ dailyPostLimit: 3 }); + expect(dailyPostsRemaining(userId, groupId)).toBe(3); + insertClip(groupId, userId); // a 'ready' clip by the user, created now + insertClip(groupId, userId); + expect(dailyPostsRemaining(userId, groupId)).toBe(1); + }); + + it('never goes negative', () => { + const { groupId, userId } = setup({ dailyPostLimit: 1 }); + insertClip(groupId, userId); + insertClip(groupId, userId); + expect(dailyPostsRemaining(userId, groupId)).toBe(0); + }); +}); diff --git a/src/lib/server/credits.ts b/src/lib/server/credits.ts index badebe9..e527da6 100644 --- a/src/lib/server/credits.ts +++ b/src/lib/server/credits.ts @@ -14,9 +14,19 @@ export interface ReciprocityConfig { ratio: number; /** Launch cutoff: only clips created at/after this earn credits. null = no cutoff. */ startedAt: Date | null; + /** Hard ceiling on posts per rolling 24h, on top of credits. null = unlimited. */ + dailyPostLimit: number | null; } -const FALLBACK: ReciprocityConfig = { seed: 5, cap: 5, ratio: 1, startedAt: null }; +const FALLBACK: ReciprocityConfig = { + seed: 5, + cap: 5, + ratio: 1, + startedAt: null, + dailyPostLimit: null +}; + +const DAY_MS = 24 * 60 * 60 * 1000; /** * Read a group's reciprocity tuning. Falls back to 5/5/1 if the group is missing. @@ -27,7 +37,8 @@ export function getReciprocityConfig(groupId: string): ReciprocityConfig { seed: groups.reciprocitySeed, cap: groups.reciprocityCap, ratio: groups.reciprocityRatio, - startedAt: groups.reciprocityStartedAt + startedAt: groups.reciprocityStartedAt, + dailyPostLimit: groups.dailyPostLimit }) .from(groups) .where(eq(groups.id, groupId)) @@ -38,10 +49,32 @@ export function getReciprocityConfig(groupId: string): ReciprocityConfig { seed: group.seed, cap: group.cap, ratio: Math.max(1, group.ratio), - startedAt: group.startedAt ?? null + startedAt: group.startedAt ?? null, + dailyPostLimit: group.dailyPostLimit ?? null }; } +/** Count clips this user has posted to the feed in the last rolling 24h. */ +export function postsInLast24h(userId: string): number { + const since = new Date(Date.now() - DAY_MS); + const [row] = db + .select({ count: sql`count(*)` }) + .from(clips) + .where(and(eq(clips.addedBy, userId), eq(clips.status, 'ready'), gte(clips.createdAt, since))) + .all(); + return row?.count ?? 0; +} + +/** + * Posts the user may still make in the current rolling 24h window, or null if the group + * has no daily cap. A hard ceiling layered on top of credits. + */ +export function dailyPostsRemaining(userId: string, groupId: string): number | null { + const { dailyPostLimit } = getReciprocityConfig(groupId); + if (dailyPostLimit === null) return null; + return Math.max(0, dailyPostLimit - postsInLast24h(userId)); +} + /** * Current post-credit balance for a user (0 if user is missing). */ diff --git a/src/lib/server/db/migrations/0035_daily_post_limit.sql b/src/lib/server/db/migrations/0035_daily_post_limit.sql new file mode 100644 index 0000000..2ae116a --- /dev/null +++ b/src/lib/server/db/migrations/0035_daily_post_limit.sql @@ -0,0 +1 @@ +ALTER TABLE `groups` ADD `daily_post_limit` integer; diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json index f62d889..1664127 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1780072362389, "tag": "0034_reciprocity_cutoff", "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1780072362390, + "tag": "0035_daily_post_limit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 2fe529e..58ab713 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -17,6 +17,8 @@ export const groups = sqliteTable('groups', { // Launch cutoff: only clips created at/after this time mint watch credits. Clips that // predate it stay fully watchable but pay out zero, so the backlog can't be farmed. reciprocityStartedAt: integer('reciprocity_started_at', { mode: 'timestamp' }), + // Optional hard ceiling on posts per rolling 24h, on top of credits. Null = unlimited. + dailyPostLimit: integer('daily_post_limit'), shortcutToken: text('shortcut_token').unique(), shortcutUrl: text('shortcut_url'), createdBy: text('created_by'), diff --git a/src/lib/server/queue.ts b/src/lib/server/queue.ts index ccd06b5..0f9a303 100644 --- a/src/lib/server/queue.ts +++ b/src/lib/server/queue.ts @@ -2,7 +2,7 @@ import { db } from '$lib/server/db'; import { clips, clipQueue } from '$lib/server/db/schema'; import { eq, and, sql, asc, desc } from 'drizzle-orm'; import { notifyNewClip } from '$lib/server/push'; -import { spendCredit, earnCredit } from '$lib/server/credits'; +import { spendCredit, earnCredit, dailyPostsRemaining } from '$lib/server/credits'; import { createLogger } from '$lib/server/logger'; import { v4 as uuid } from 'uuid'; @@ -88,14 +88,22 @@ export async function setClipReady( .where(eq(clips.id, clipId)) .all(); - if (clip && !spendCredit(clip.addedBy)) { - // No credit available anymore — degrade to a held queue entry instead of posting free. + // Over the daily post cap → degrade to the queue without spending a credit. + const overDailyCap = clip + ? (() => { + const remaining = dailyPostsRemaining(clip.addedBy, clip.groupId); + return remaining !== null && remaining <= 0; + })() + : false; + + if (clip && (overDailyCap || !spendCredit(clip.addedBy))) { + // No credit (or over the daily cap) — degrade to a held queue entry, never post free. enqueueClip(clipId, clip.addedBy, clip.groupId); db.update(clips) .set({ ...updates, status: 'queued' }) .where(eq(clips.id, clipId)) .run(); - log.info({ clipId }, 'direct post had no credit, held in queue'); + log.info({ clipId, overDailyCap }, 'direct post deferred, held in queue'); return 'queued'; } @@ -218,8 +226,11 @@ export async function postQueuedClips( const byId = new Map(rows.map((r) => [r.id, r])); const posted: string[] = []; + // Hard daily ceiling on top of credits (null = unlimited). + const remaining = dailyPostsRemaining(userId, groupId); for (const entryId of entryIds) { + if (remaining !== null && posted.length >= remaining) break; // daily cap reached const row = byId.get(entryId); if (!row || row.status !== 'queued') continue; // ownership + state guard if (!spendCredit(userId)) break; // out of credits — stop diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 585d5f3..129ef68 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -356,6 +356,7 @@ currentSeed={group.reciprocitySeed} currentCap={group.reciprocityCap} currentRatio={group.reciprocityRatio} + currentDailyPostLimit={group.dailyPostLimit} />
diff --git a/src/routes/api/__tests__/group-reciprocity.test.ts b/src/routes/api/__tests__/group-reciprocity.test.ts index b295994..5842b7a 100644 --- a/src/routes/api/__tests__/group-reciprocity.test.ts +++ b/src/routes/api/__tests__/group-reciprocity.test.ts @@ -40,7 +40,20 @@ describe('PATCH /api/group/reciprocity', () => { const res = await patch({ seed: 3, cap: 8, ratio: 2 }); expect(res.status).toBe(200); const body = await res.json(); - expect(body).toEqual({ seed: 3, cap: 8, ratio: 2 }); + expect(body).toEqual({ seed: 3, cap: 8, ratio: 2, dailyPostLimit: null }); + }); + + it('sets and clears the daily post limit', async () => { + const set = await patch({ dailyPostLimit: 10 }); + expect(set.status).toBe(200); + expect((await set.json()).dailyPostLimit).toBe(10); + const clear = await patch({ dailyPostLimit: null }); + expect((await clear.json()).dailyPostLimit).toBeNull(); + }); + + it('rejects an out-of-range daily post limit', async () => { + const res = await patch({ dailyPostLimit: 0 }); + expect(res.status).toBe(400); }); it('rejects seed greater than cap', async () => { diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 25994e5..711846f 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -34,7 +34,7 @@ import { } from '$lib/server/api-utils'; import { extractMentions, notifyMentions } from '$lib/server/mentions'; import { enqueueClip, getQueueCount } from '$lib/server/queue'; -import { getCredits } from '$lib/server/credits'; +import { getCredits, dailyPostsRemaining } from '$lib/server/credits'; import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); @@ -441,7 +441,9 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } // spent at PUBLISH time inside setClipReady — so a failed download never costs anything. const hasCredit = getCredits(user.id) >= 1; const queueEmpty = getQueueCount(user.id, user.groupId) === 0; - const directPost = hasCredit && queueEmpty; + const dailyRemaining = dailyPostsRemaining(user.id, user.groupId); + const underDailyCap = dailyRemaining === null || dailyRemaining > 0; + const directPost = hasCredit && queueEmpty && underDailyCap; const clipId = uuid(); const now = new Date(); diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index 376e900..c8e21b3 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -17,7 +17,7 @@ import { getActiveProvider } from '$lib/server/providers/registry'; import { createLogger } from '$lib/server/logger'; import { authenticateShortcutToken } from '$lib/server/shortcut-auth'; import { enqueueClip, getQueueCount } from '$lib/server/queue'; -import { getCredits } from '$lib/server/credits'; +import { getCredits, dailyPostsRemaining } from '$lib/server/credits'; const log = createLogger('share'); @@ -130,10 +130,13 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { return shareResponse(false, '❌ This clip has already been shared!', 409); } - // Reciprocity: post straight to the feed only with a credit in hand and an empty queue; - // otherwise hold it in the queue. Credit is spent at publish time (setClipReady). + // Reciprocity: post straight to the feed only with a credit in hand, an empty queue, and + // under the daily cap; otherwise hold it in the queue. Credit spent at publish (setClipReady). + const dailyRemaining = dailyPostsRemaining(matchedUser.id, group.id); const directPost = - getCredits(matchedUser.id) >= 1 && getQueueCount(matchedUser.id, group.id) === 0; + getCredits(matchedUser.id) >= 1 && + getQueueCount(matchedUser.id, group.id) === 0 && + (dailyRemaining === null || dailyRemaining > 0); // 8. Create clip + auto-watched in a transaction so both succeed or fail together const clipId = uuid(); diff --git a/src/routes/api/group/reciprocity/+server.ts b/src/routes/api/group/reciprocity/+server.ts index cd2a1ad..14e0847 100644 --- a/src/routes/api/group/reciprocity/+server.ts +++ b/src/routes/api/group/reciprocity/+server.ts @@ -9,6 +9,7 @@ const CAP_MIN = 1; const CAP_MAX = 20; const RATIO_MIN = 1; const RATIO_MAX = 10; +const DAILY_MAX = 100; function asInt(v: unknown, fallback: number): number | null { if (v === undefined) return fallback; @@ -17,13 +18,33 @@ function asInt(v: unknown, fallback: number): number | null { } export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { - const body = await parseBody<{ seed?: number; cap?: number; ratio?: number }>(request); + const body = await parseBody<{ + seed?: number; + cap?: number; + ratio?: number; + dailyPostLimit?: number | null; + }>(request); if (isResponse(body)) return body; const seed = asInt(body.seed, group.reciprocitySeed); const cap = asInt(body.cap, group.reciprocityCap); const ratio = asInt(body.ratio, group.reciprocityRatio); + // dailyPostLimit is nullable: undefined keeps current, null clears it, a number sets it. + let dailyPostLimit: number | null; + if (body.dailyPostLimit === undefined) { + dailyPostLimit = group.dailyPostLimit; + } else if (body.dailyPostLimit === null) { + dailyPostLimit = null; + } else if (Number.isInteger(body.dailyPostLimit) && body.dailyPostLimit >= 1) { + dailyPostLimit = body.dailyPostLimit; + } else { + return badRequest(`dailyPostLimit must be null or an integer ≥ 1`); + } + if (dailyPostLimit !== null && dailyPostLimit > DAILY_MAX) { + return badRequest(`dailyPostLimit must be ≤ ${DAILY_MAX}`); + } + if (seed === null || cap === null || ratio === null) { return badRequest('seed, cap, and ratio must be integers'); } @@ -39,7 +60,7 @@ export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => db.transaction((tx) => { tx.update(groups) - .set({ reciprocitySeed: seed, reciprocityCap: cap, reciprocityRatio: ratio }) + .set({ reciprocitySeed: seed, reciprocityCap: cap, reciprocityRatio: ratio, dailyPostLimit }) .where(eq(groups.id, group.id)) .run(); // Lowering the cap shouldn't strand members above it — clamp existing balances. @@ -49,5 +70,5 @@ export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => .run(); }); - return json({ seed, cap, ratio }); + return json({ seed, cap, ratio, dailyPostLimit }); }); From 36380663705a4081aa2690304867dece92881877 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Fri, 29 May 2026 14:43:26 -0500 Subject: [PATCH 11/11] fix(e2e): silence WebKit cancelled-request noise in page watcher The requestfailed filter checked for capital 'Cancelled' but WebKit reports lowercase 'Load request cancelled', so navigation-cancelled video/font loads leaked through and flaked back-gestures on mobile-ios only. Match case-insensitively and on both spellings. --- e2e/helpers/page-listeners.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/helpers/page-listeners.ts b/e2e/helpers/page-listeners.ts index e5d628e..e072a33 100644 --- a/e2e/helpers/page-listeners.ts +++ b/e2e/helpers/page-listeners.ts @@ -58,8 +58,11 @@ export function watchPage( const url = req.url(); if (matchesIgnoreReq(url)) return; const failure = req.failure()?.errorText ?? 'unknown'; - // Filter out aborted requests — they often happen during navigation. - if (failure.includes('aborted') || failure.includes('Cancelled')) return; + // Filter out aborted/cancelled requests — they happen routinely during navigation as + // in-flight video/font loads are torn down. Match case-insensitively and both spellings: + // Chromium says "net::ERR_ABORTED"/"canceled", WebKit says "Load request cancelled". + const failureLc = failure.toLowerCase(); + if (failureLc.includes('aborted') || failureLc.includes('cancel')) return; issues.push({ kind: 'request-failed', url,