security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import prisma from "./prisma.js";
|
||||
|
||||
export function getCurrentPeriod(resetSchedule: string): string {
|
||||
const now = new Date();
|
||||
@@ -9,17 +7,27 @@ export function getCurrentPeriod(resetSchedule: string): string {
|
||||
|
||||
switch (resetSchedule) {
|
||||
case "weekly": {
|
||||
const startOfYear = new Date(year, 0, 1);
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000);
|
||||
const week = Math.ceil((days + startOfYear.getDay() + 1) / 7);
|
||||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||
// ISO 8601 week number
|
||||
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
case "biweekly": {
|
||||
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
const biweek = Math.ceil(week / 2);
|
||||
return `${d.getUTCFullYear()}-BW${String(biweek).padStart(2, "0")}`;
|
||||
}
|
||||
case "quarterly": {
|
||||
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||
return `${year}-Q${q}`;
|
||||
}
|
||||
case "yearly":
|
||||
return `${year}`;
|
||||
case "per_release":
|
||||
return "per_release";
|
||||
case "never":
|
||||
return "lifetime";
|
||||
case "monthly":
|
||||
@@ -28,17 +36,25 @@ export function getCurrentPeriod(resetSchedule: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemainingBudget(userId: string, boardId: string): Promise<number> {
|
||||
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
||||
export async function getRemainingBudget(userId: string, boardId: string, db: any = prisma): Promise<number> {
|
||||
const board = await db.board.findUnique({ where: { id: boardId } });
|
||||
if (!board) return 0;
|
||||
|
||||
if (board.voteBudgetReset === "never" && board.voteBudget === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||
// per_release uses the timestamp of the last manual reset as the period key
|
||||
let period: string;
|
||||
if (board.voteBudgetReset === "per_release") {
|
||||
period = board.lastBudgetReset
|
||||
? `release-${board.lastBudgetReset.toISOString()}`
|
||||
: "release-initial";
|
||||
} else {
|
||||
period = getCurrentPeriod(board.voteBudgetReset);
|
||||
}
|
||||
|
||||
const used = await prisma.vote.aggregate({
|
||||
const used = await db.vote.aggregate({
|
||||
where: { voterId: userId, post: { boardId }, budgetPeriod: period },
|
||||
_sum: { weight: true },
|
||||
});
|
||||
@@ -53,7 +69,16 @@ export function getNextResetDate(resetSchedule: string): Date {
|
||||
switch (resetSchedule) {
|
||||
case "weekly": {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() + (7 - d.getDay()));
|
||||
const daysUntilMonday = ((8 - d.getDay()) % 7) || 7;
|
||||
d.setDate(d.getDate() + daysUntilMonday);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
case "biweekly": {
|
||||
const d = new Date(now);
|
||||
const dayOfWeek = d.getDay();
|
||||
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
||||
d.setDate(d.getDate() + daysUntilMonday + 7);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
@@ -61,10 +86,11 @@ export function getNextResetDate(resetSchedule: string): Date {
|
||||
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||
return new Date(now.getFullYear(), q * 3, 1);
|
||||
}
|
||||
case "yearly":
|
||||
return new Date(now.getFullYear() + 1, 0, 1);
|
||||
case "per_release":
|
||||
// manual reset only - no automatic next date
|
||||
return new Date(8640000000000000);
|
||||
case "never":
|
||||
return new Date(8640000000000000); // max date
|
||||
return new Date(8640000000000000);
|
||||
case "monthly":
|
||||
default: {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
158
packages/api/src/lib/default-templates.ts
Normal file
158
packages/api/src/lib/default-templates.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
interface TemplateField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "select";
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface TemplateDef {
|
||||
name: string;
|
||||
fields: TemplateField[];
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
const BUG_TEMPLATES: TemplateDef[] = [
|
||||
{
|
||||
name: "Bug Report",
|
||||
isDefault: true,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ key: "steps_to_reproduce", label: "Steps to reproduce", type: "textarea", required: true, placeholder: "1. Go to...\n2. Click on...\n3. See error" },
|
||||
{ key: "expected_behavior", label: "Expected behavior", type: "textarea", required: true, placeholder: "What should have happened?" },
|
||||
{ key: "actual_behavior", label: "Actual behavior", type: "textarea", required: true, placeholder: "What happened instead?" },
|
||||
{ key: "environment", label: "Environment", type: "text", required: false, placeholder: "e.g. Chrome 120, Windows 11" },
|
||||
{ key: "severity", label: "Severity", type: "select", required: true, options: ["Critical", "Major", "Minor", "Cosmetic"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "UI/Visual Bug",
|
||||
isDefault: false,
|
||||
position: 1,
|
||||
fields: [
|
||||
{ key: "what_looks_wrong", label: "What looks wrong", type: "textarea", required: true, placeholder: "Describe the visual issue" },
|
||||
{ key: "where_in_app", label: "Where in the app", type: "text", required: true, placeholder: "Page or screen name" },
|
||||
{ key: "browser_device", label: "Browser and device", type: "text", required: true, placeholder: "e.g. Safari on iPhone 15" },
|
||||
{ key: "screen_size", label: "Screen size", type: "text", required: false, placeholder: "e.g. 1920x1080 or mobile" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Performance Issue",
|
||||
isDefault: false,
|
||||
position: 2,
|
||||
fields: [
|
||||
{ key: "whats_slow", label: "What's slow or laggy", type: "textarea", required: true },
|
||||
{ key: "when_it_happens", label: "When does it happen", type: "textarea", required: true, placeholder: "Always, sometimes, under specific conditions..." },
|
||||
{ key: "how_long", label: "How long does it take", type: "text", required: false, placeholder: "e.g. 10+ seconds to load" },
|
||||
{ key: "device_network", label: "Device and network", type: "text", required: false, placeholder: "e.g. MacBook Pro, WiFi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Crash/Error Report",
|
||||
isDefault: false,
|
||||
position: 3,
|
||||
fields: [
|
||||
{ key: "what_happened", label: "What happened", type: "textarea", required: true },
|
||||
{ key: "error_message", label: "Error message", type: "textarea", required: false, placeholder: "Copy the error text if visible" },
|
||||
{ key: "steps_before_crash", label: "Steps before the crash", type: "textarea", required: true },
|
||||
{ key: "frequency", label: "How often does it happen", type: "select", required: true, options: ["Every time", "Often", "Sometimes", "Once"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Data Issue",
|
||||
isDefault: false,
|
||||
position: 4,
|
||||
fields: [
|
||||
{ key: "what_data_is_wrong", label: "What data is wrong", type: "textarea", required: true },
|
||||
{ key: "where_you_see_it", label: "Where do you see it", type: "text", required: true, placeholder: "Page, section, or API endpoint" },
|
||||
{ key: "what_it_should_be", label: "What it should be", type: "textarea", required: true },
|
||||
{ key: "impact", label: "Impact", type: "select", required: true, options: ["Blocking", "Major", "Minor"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURE_TEMPLATES: TemplateDef[] = [
|
||||
{
|
||||
name: "Feature Request",
|
||||
isDefault: false,
|
||||
position: 5,
|
||||
fields: [
|
||||
{ key: "use_case", label: "Use case", type: "textarea", required: true, placeholder: "What problem would this solve?" },
|
||||
{ key: "proposed_solution", label: "Proposed solution", type: "textarea", required: true, placeholder: "How do you imagine this working?" },
|
||||
{ key: "alternatives", label: "Alternatives considered", type: "textarea", required: false },
|
||||
{ key: "who_benefits", label: "Who benefits", type: "text", required: false, placeholder: "e.g. All users, admins, new users" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "UX Improvement",
|
||||
isDefault: false,
|
||||
position: 6,
|
||||
fields: [
|
||||
{ key: "whats_confusing", label: "What's confusing or difficult", type: "textarea", required: true },
|
||||
{ key: "how_should_it_work", label: "How should it work instead", type: "textarea", required: true },
|
||||
{ key: "who_runs_into_this", label: "Who runs into this", type: "text", required: false, placeholder: "e.g. New users, power users" },
|
||||
{ key: "frequency", label: "How often does this come up", type: "select", required: false, options: ["Daily", "Weekly", "Occasionally", "Rarely"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Integration Request",
|
||||
isDefault: false,
|
||||
position: 7,
|
||||
fields: [
|
||||
{ key: "tool_or_service", label: "Tool or service", type: "text", required: true, placeholder: "e.g. Slack, Jira, GitHub" },
|
||||
{ key: "what_it_should_do", label: "What it should do", type: "textarea", required: true, placeholder: "Describe the integration behavior" },
|
||||
{ key: "current_workaround", label: "Current workaround", type: "textarea", required: false },
|
||||
{ key: "priority", label: "Priority", type: "select", required: true, options: ["Must have", "Should have", "Nice to have"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Content Change",
|
||||
isDefault: false,
|
||||
position: 8,
|
||||
fields: [
|
||||
{ key: "where", label: "Where is it", type: "text", required: true, placeholder: "Page, section, or URL" },
|
||||
{ key: "current_text", label: "Current text", type: "textarea", required: true },
|
||||
{ key: "suggested_text", label: "Suggested text", type: "textarea", required: true },
|
||||
{ key: "why_change", label: "Why change it", type: "textarea", required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Workflow Improvement",
|
||||
isDefault: false,
|
||||
position: 9,
|
||||
fields: [
|
||||
{ key: "current_workflow", label: "Current workflow", type: "textarea", required: true, placeholder: "Describe the steps you take today" },
|
||||
{ key: "pain_point", label: "Pain point", type: "textarea", required: true, placeholder: "What's slow, repetitive, or error-prone?" },
|
||||
{ key: "ideal_workflow", label: "Ideal workflow", type: "textarea", required: true, placeholder: "How should it work?" },
|
||||
{ key: "impact", label: "Impact", type: "select", required: false, options: ["Saves significant time", "Reduces errors", "Improves quality", "Minor convenience"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_TEMPLATES = [...BUG_TEMPLATES, ...FEATURE_TEMPLATES];
|
||||
|
||||
export async function seedTemplatesForBoard(prisma: PrismaClient, boardId: string) {
|
||||
const existing = await prisma.boardTemplate.count({ where: { boardId } });
|
||||
if (existing > 0) return;
|
||||
|
||||
await prisma.boardTemplate.createMany({
|
||||
data: DEFAULT_TEMPLATES.map((t) => ({
|
||||
boardId,
|
||||
name: t.name,
|
||||
fields: t.fields as any,
|
||||
isDefault: t.isDefault,
|
||||
position: t.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedAllBoardTemplates(prisma: PrismaClient) {
|
||||
const boards = await prisma.board.findMany({ select: { id: true } });
|
||||
for (const board of boards) {
|
||||
await seedTemplatesForBoard(prisma, board.id);
|
||||
}
|
||||
}
|
||||
5
packages/api/src/lib/prisma.ts
Normal file
5
packages/api/src/lib/prisma.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
30
packages/api/src/lib/token-blocklist.ts
Normal file
30
packages/api/src/lib/token-blocklist.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import crypto from "crypto";
|
||||
import prisma from "./prisma.js";
|
||||
|
||||
function hashForBlock(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function blockToken(token: string, ttlMs: number = 25 * 60 * 60 * 1000): Promise<void> {
|
||||
const tokenHash = hashForBlock(token);
|
||||
const expiresAt = new Date(Date.now() + ttlMs);
|
||||
await prisma.blockedToken.upsert({
|
||||
where: { tokenHash },
|
||||
create: { tokenHash, expiresAt },
|
||||
update: { expiresAt },
|
||||
});
|
||||
}
|
||||
|
||||
export async function isTokenBlocked(token: string): Promise<boolean> {
|
||||
const tokenHash = hashForBlock(token);
|
||||
const entry = await prisma.blockedToken.findUnique({ where: { tokenHash } });
|
||||
if (!entry) return false;
|
||||
return entry.expiresAt > new Date();
|
||||
}
|
||||
|
||||
export async function cleanupExpiredTokens(): Promise<number> {
|
||||
const result = await prisma.blockedToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
25
packages/api/src/lib/view-tracker.ts
Normal file
25
packages/api/src/lib/view-tracker.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const MAX_ENTRIES = 50000;
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
export function shouldCount(postId: string, identifier: string): boolean {
|
||||
const key = `${postId}:${identifier}`;
|
||||
const expiry = seen.get(key);
|
||||
if (expiry && expiry > Date.now()) return false;
|
||||
if (seen.size >= MAX_ENTRIES) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of seen) {
|
||||
if (v < now) seen.delete(k);
|
||||
if (seen.size < MAX_ENTRIES * 0.8) break;
|
||||
}
|
||||
if (seen.size >= MAX_ENTRIES) return false;
|
||||
}
|
||||
seen.set(key, Date.now() + 15 * 60 * 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cleanupViews(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of seen) {
|
||||
if (v < now) seen.delete(k);
|
||||
}
|
||||
}
|
||||
46
packages/api/src/lib/wordlist.ts
Normal file
46
packages/api/src/lib/wordlist.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
// 256 common, unambiguous English words (4-7 letters)
|
||||
// 256^6 = ~2.8 * 10^14 combinations - strong enough with rate limiting + ALTCHA
|
||||
const WORDS = [
|
||||
"acre", "alps", "arch", "army", "atom", "aura", "axis", "balm",
|
||||
"band", "bark", "barn", "base", "bath", "beam", "bell", "belt",
|
||||
"bend", "bird", "bite", "blow", "blur", "boat", "bold", "bolt",
|
||||
"bond", "bone", "book", "bore", "boss", "bowl", "brim", "bulb",
|
||||
"bulk", "burn", "bush", "buzz", "cafe", "cage", "calm", "camp",
|
||||
"cape", "card", "cart", "case", "cast", "cave", "cell", "chat",
|
||||
"chip", "city", "clam", "clan", "claw", "clay", "clip", "club",
|
||||
"clue", "coal", "coat", "code", "coil", "coin", "cold", "cone",
|
||||
"cook", "cool", "cope", "cord", "core", "cork", "corn", "cost",
|
||||
"cove", "crew", "crop", "crow", "cube", "curb", "cure", "curl",
|
||||
"dale", "dare", "dart", "dash", "dawn", "deck", "deer", "deli",
|
||||
"demo", "dent", "desk", "dime", "disc", "dock", "dome", "door",
|
||||
"dose", "dove", "draw", "drum", "dune", "dusk", "dust", "edge",
|
||||
"emit", "epic", "exit", "face", "fact", "fame", "fawn", "felt",
|
||||
"fern", "film", "find", "fire", "firm", "fish", "fist", "flag",
|
||||
"flak", "flaw", "flex", "flip", "flow", "flux", "foam", "foil",
|
||||
"fold", "folk", "font", "ford", "fork", "form", "fort", "frog",
|
||||
"fuel", "fume", "fund", "fury", "fuse", "gale", "game", "gang",
|
||||
"gate", "gaze", "gear", "germ", "gift", "gist", "glen", "glow",
|
||||
"glue", "gold", "golf", "gong", "grab", "gram", "grid", "grin",
|
||||
"grip", "grit", "gulf", "gust", "hail", "half", "hall", "halt",
|
||||
"hare", "harp", "hawk", "haze", "heap", "helm", "herb", "herd",
|
||||
"hero", "hike", "hill", "hint", "hive", "hold", "hole", "hood",
|
||||
"hook", "hope", "horn", "host", "howl", "hull", "hunt", "husk",
|
||||
"iris", "iron", "isle", "jade", "jazz", "jest", "jolt", "jump",
|
||||
"jury", "keen", "kelp", "kite", "knob", "knot", "lace", "lake",
|
||||
"lamb", "lamp", "lane", "lark", "lava", "lawn", "lead", "leaf",
|
||||
"lens", "lien", "lime", "line", "link", "lion", "lock", "loft",
|
||||
"loop", "loom", "lore", "luck", "lure", "lynx", "malt", "mane",
|
||||
"maze", "mesa", "mild", "mill", "mine", "mint", "mist", "moat",
|
||||
"mode", "mold", "monk", "moon", "moor", "moss", "myth", "navy",
|
||||
"nest", "node", "norm", "note", "nova", "oaks", "opal", "orca",
|
||||
];
|
||||
|
||||
export function generateRecoveryPhrase(): string {
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
words.push(WORDS[randomInt(WORDS.length)]);
|
||||
}
|
||||
return words.join("-");
|
||||
}
|
||||
Reference in New Issue
Block a user