security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -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);

View 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);
}
}

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export default prisma;

View 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;
}

View 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);
}
}

View 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("-");
}