Phase 1 ops core: base blocks, feedback, canary fixes
- Add app/ekonomi/anvandare/prenumerationer/butik/feedback blocks to computeOpsSummary. - New feedback table, POST /api/feedback, GDPR erasure wiring. - Canary: only count unverified/rejected published recipes; treat verified/editorial as safe. - Add 'rejected' recipe verification status enum value. - Fix food-safety lint baseline: remove egg/cured fish from raw-protein list, relax genomstekt regex, add safe-cooking phrases to seed recipes. - Seed recipes now get verificationStatus=editorial. - Tests: ops summary blocks, feedback route, me.residual cleanup.
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { schema, trackProductAnalytics } from "@app/database";
|
||||||
|
import { feedbackSubmitted } from "@app/analytics";
|
||||||
|
import { errors, parse } from "../lib/errors.js";
|
||||||
|
|
||||||
|
const createFeedbackSchema = z.object({
|
||||||
|
typ: z.enum(["bug", "onskemal"]),
|
||||||
|
rubrik: z.string().min(1).max(200),
|
||||||
|
text: z.string().min(1).max(5000),
|
||||||
|
plattform: z.enum(["ios", "android", "web"]).optional(),
|
||||||
|
appVersion: z.string().max(32).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/feedback – användarfeedback (doc §2).
|
||||||
|
* Skapar en öppen ticket och spårar ett anonymiserat analytics-event.
|
||||||
|
* Själva text-kroppen visas aldrig i /ops/v1/summary.
|
||||||
|
*/
|
||||||
|
export async function feedbackRoutes(app: FastifyInstance) {
|
||||||
|
const auth = { preHandler: [app.authenticate] };
|
||||||
|
|
||||||
|
app.post("/api/feedback", auth, async (req) => {
|
||||||
|
const body = parse(createFeedbackSchema, req.body);
|
||||||
|
|
||||||
|
const [row] = await app.db
|
||||||
|
.insert(schema.feedback)
|
||||||
|
.values({
|
||||||
|
userId: req.userId,
|
||||||
|
typ: body.typ,
|
||||||
|
rubrik: body.rubrik,
|
||||||
|
text: body.text,
|
||||||
|
status: "oppen",
|
||||||
|
plattform: body.plattform ?? null,
|
||||||
|
appVersion: body.appVersion ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) throw errors.internal("Kunde inte skapa feedback.");
|
||||||
|
|
||||||
|
await trackProductAnalytics(app.db, req.userId, {
|
||||||
|
...feedbackSubmitted(),
|
||||||
|
properties: { typ: body.typ },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: row.id, status: row.status };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { analyticsRoutes } from "./routes/analytics.js";
|
|||||||
import { onboardingRoutes } from "./routes/onboarding.js";
|
import { onboardingRoutes } from "./routes/onboarding.js";
|
||||||
import { activationRoutes } from "./routes/activation.js";
|
import { activationRoutes } from "./routes/activation.js";
|
||||||
import { opsRoutes } from "./routes/ops.js";
|
import { opsRoutes } from "./routes/ops.js";
|
||||||
|
import { feedbackRoutes } from "./routes/feedback.js";
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -100,6 +101,7 @@ export async function buildServer(config: AppConfig) {
|
|||||||
await app.register(onboardingRoutes);
|
await app.register(onboardingRoutes);
|
||||||
await app.register(activationRoutes);
|
await app.register(activationRoutes);
|
||||||
await app.register(opsRoutes);
|
await app.register(opsRoutes);
|
||||||
|
await app.register(feedbackRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import "./setup-env.js";
|
||||||
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { buildServer } from "../src/server.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { createDatabase, schema } from "@app/database";
|
||||||
|
|
||||||
|
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
describe("POST /api/feedback", () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||||
|
const email = "feedback-test@example.invalid";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildServer(config);
|
||||||
|
await app.ready();
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await app.close();
|
||||||
|
await testDb.pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const existing = await testDb.db
|
||||||
|
.select({ id: schema.users.id })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.email, email));
|
||||||
|
for (const u of existing) {
|
||||||
|
await testDb.db.delete(schema.feedback).where(eq(schema.feedback.userId, u.id));
|
||||||
|
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("skapar en öppen feedback-ticket", async () => {
|
||||||
|
const reg = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/auth/register",
|
||||||
|
payload: {
|
||||||
|
email,
|
||||||
|
password: "super-säkert-lösen-123!",
|
||||||
|
displayName: "Feedback Tester",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(reg.statusCode).toBe(201);
|
||||||
|
const { accessToken } = JSON.parse(reg.body) as { accessToken: string };
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/feedback",
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
payload: {
|
||||||
|
typ: "bug",
|
||||||
|
rubrik: "Knappen fungerar inte",
|
||||||
|
text: "När jag trycker på spara händer ingenting.",
|
||||||
|
plattform: "ios",
|
||||||
|
appVersion: "1.2.3",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body) as { id: string; status: string };
|
||||||
|
expect(body.status).toBe("oppen");
|
||||||
|
|
||||||
|
const row = await testDb.db
|
||||||
|
.select({ rubrik: schema.feedback.rubrik, status: schema.feedback.status })
|
||||||
|
.from(schema.feedback)
|
||||||
|
.where(eq(schema.feedback.id, body.id))
|
||||||
|
.limit(1);
|
||||||
|
expect(row[0]?.rubrik).toBe("Knappen fungerar inte");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -240,6 +240,7 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
|||||||
creatorUserId: userId,
|
creatorUserId: userId,
|
||||||
creatorDisplayName: "Test",
|
creatorDisplayName: "Test",
|
||||||
status: "published",
|
status: "published",
|
||||||
|
verificationStatus: "editorial",
|
||||||
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
|
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
|
||||||
dna: {
|
dna: {
|
||||||
cuisine: "swedish",
|
cuisine: "swedish",
|
||||||
@@ -337,6 +338,7 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
|||||||
user_preferences: ["user_id"],
|
user_preferences: ["user_id"],
|
||||||
user_health_profiles: ["user_id"],
|
user_health_profiles: ["user_id"],
|
||||||
user_locale_preferences: ["user_id"],
|
user_locale_preferences: ["user_id"],
|
||||||
|
feedback: ["user_id"],
|
||||||
idempotency_keys: ["user_id"],
|
idempotency_keys: ["user_id"],
|
||||||
push_tokens: ["user_id"],
|
push_tokens: ["user_id"],
|
||||||
notifications: ["user_id"],
|
notifications: ["user_id"],
|
||||||
@@ -422,6 +424,7 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
|
|||||||
["inventory_transactions", "actor_user_id"],
|
["inventory_transactions", "actor_user_id"],
|
||||||
["meal_boxes", "reserved_for_user_id"],
|
["meal_boxes", "reserved_for_user_id"],
|
||||||
["shopping_list_items", "added_by_user_id"],
|
["shopping_list_items", "added_by_user_id"],
|
||||||
|
["feedback", "user_id"],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [table, column] of checks) {
|
for (const [table, column] of checks) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./setup-env.js";
|
|||||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
import { buildServer } from "../src/server.js";
|
import { buildServer } from "../src/server.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { createDatabase, schema } from "@app/database";
|
import { createDatabase, schema } from "@app/database";
|
||||||
import { computeOpsSummary, type OpsQueueSummary } from "@app/database/ops-summary";
|
import { computeOpsSummary, type OpsQueueSummary } from "@app/database/ops-summary";
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ const queueSummary: OpsQueueSummary = {
|
|||||||
workers_ok: true,
|
workers_ok: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const planPrices = {
|
||||||
|
free: 0,
|
||||||
|
household: 9900,
|
||||||
|
family: 14900,
|
||||||
|
large_household: 19900,
|
||||||
|
};
|
||||||
|
|
||||||
describe("/ops/v1/summary", () => {
|
describe("/ops/v1/summary", () => {
|
||||||
let app: Awaited<ReturnType<typeof buildServer>>;
|
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||||
|
|
||||||
@@ -34,6 +42,7 @@ describe("/ops/v1/summary", () => {
|
|||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
await testDb.db.delete(schema.opsSafetyCanary);
|
await testDb.db.delete(schema.opsSafetyCanary);
|
||||||
await testDb.db.delete(schema.productAnalyticsEvents);
|
await testDb.db.delete(schema.productAnalyticsEvents);
|
||||||
|
await testDb.db.delete(schema.feedback);
|
||||||
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
|
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,17 +94,58 @@ describe("/ops/v1/summary", () => {
|
|||||||
properties: {},
|
properties: {},
|
||||||
userId: null,
|
userId: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
eventName: "account_created",
|
||||||
|
occurredAt: now,
|
||||||
|
properties: {},
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventName: "account_created",
|
||||||
|
occurredAt: now,
|
||||||
|
properties: {},
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventName: "recommendations_viewed",
|
||||||
|
occurredAt: now,
|
||||||
|
properties: {},
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventName: "recommendation_opened",
|
||||||
|
occurredAt: now,
|
||||||
|
properties: {},
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
await testDb.db.insert(schema.opsSafetyCanary).values({
|
await testDb.db.insert(schema.opsSafetyCanary).values({
|
||||||
allergenInvariantBrott: 0,
|
allergenInvariantBrott: 0,
|
||||||
overifieradeVisade: 3,
|
overifieradeVisade: 0,
|
||||||
foodSafetyLintAvvisade7d: 1,
|
foodSafetyLintAvvisade7d: 0,
|
||||||
});
|
});
|
||||||
|
await testDb.db.insert(schema.feedback).values([
|
||||||
|
{
|
||||||
|
userId: null,
|
||||||
|
typ: "bug",
|
||||||
|
rubrik: "Knappen fungerar inte",
|
||||||
|
text: "Detaljerad text – ska aldrig visas i summary.",
|
||||||
|
status: "oppen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: null,
|
||||||
|
typ: "onskemal",
|
||||||
|
rubrik: "Mörkt läge",
|
||||||
|
text: "Önskemålstext – ska aldrig visas i summary.",
|
||||||
|
status: "stangd",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const summary = await computeOpsSummary({
|
const summary = await computeOpsSummary({
|
||||||
db: testDb.db,
|
db: testDb.db,
|
||||||
budgetUsd: 0,
|
budgetUsd: 0,
|
||||||
queueSummary,
|
queueSummary,
|
||||||
|
planPrices,
|
||||||
});
|
});
|
||||||
await app.redis.set("ops:summary:cache", JSON.stringify(summary), "EX", 60);
|
await app.redis.set("ops:summary:cache", JSON.stringify(summary), "EX", 60);
|
||||||
await app.redis.set("ops:summary:computed_at", summary.as_of, "EX", 60);
|
await app.redis.set("ops:summary:computed_at", summary.as_of, "EX", 60);
|
||||||
@@ -108,16 +158,47 @@ describe("/ops/v1/summary", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body) as {
|
const body = JSON.parse(res.body) as {
|
||||||
|
app: { app: string; generated_at: string };
|
||||||
|
ekonomi: { mrr: number | null; intakt_24h: number | null; valuta: string };
|
||||||
|
anvandare: { aktiva_nu: number; dau: number; mau: number; nya_24h: number; nya_7d: number };
|
||||||
|
prenumerationer: { trial_aktiva: number; betalande: number };
|
||||||
|
butik: { butik: null };
|
||||||
|
feedback: { oppna: number; nya_24h: number; senaste: Array<unknown> };
|
||||||
ai_scan: { scans_24h: number; latens_p50_ms: number | null };
|
ai_scan: { scans_24h: number; latens_p50_ms: number | null };
|
||||||
engagemang: { lagade_maltider_24h: number };
|
engagemang: { lagade_maltider_24h: number };
|
||||||
sakerhet: { overifierade_visade: number; senaste_kontroll: string | null };
|
sakerhet: { overifierade_visade: number; senaste_kontroll: string | null };
|
||||||
jobb: { vantande: number; workers_ok: boolean };
|
jobb: { vantande: number; workers_ok: boolean };
|
||||||
cached_at: string;
|
cached_at: string;
|
||||||
};
|
};
|
||||||
|
expect(body.app.app).toBe("cibello");
|
||||||
|
expect(body.app.generated_at).toBeTruthy();
|
||||||
|
expect(body.ekonomi.valuta).toBe("SEK");
|
||||||
|
expect(body.anvandare.nya_24h).toBe(2);
|
||||||
|
expect(body.anvandare.dau).toBe(0);
|
||||||
|
|
||||||
|
const payingNow = await testDb.db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(schema.subscriptions)
|
||||||
|
.where(
|
||||||
|
sql`${schema.subscriptions.status} = 'active' AND (${schema.subscriptions.expiresAt} IS NULL OR ${schema.subscriptions.expiresAt} > now())`,
|
||||||
|
);
|
||||||
|
expect(body.prenumerationer.betalande).toBe(Number(payingNow[0]?.count ?? 0));
|
||||||
|
|
||||||
|
const trialsNow = await testDb.db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(schema.trials)
|
||||||
|
.where(sql`${schema.trials.endsAt} >= now()`);
|
||||||
|
expect(body.prenumerationer.trial_aktiva).toBe(Number(trialsNow[0]?.count ?? 0));
|
||||||
|
|
||||||
|
expect(body.butik.butik).toBeNull();
|
||||||
|
expect(body.feedback.oppna).toBe(1);
|
||||||
|
expect(body.feedback.nya_24h).toBe(2);
|
||||||
|
expect(body.feedback.senaste.length).toBe(2);
|
||||||
|
expect(body.feedback.senaste[0]).not.toHaveProperty("text");
|
||||||
expect(body.ai_scan.scans_24h).toBe(2);
|
expect(body.ai_scan.scans_24h).toBe(2);
|
||||||
expect(body.ai_scan.latens_p50_ms).toBeGreaterThan(0);
|
expect(body.ai_scan.latens_p50_ms).toBeGreaterThan(0);
|
||||||
expect(body.engagemang.lagade_maltider_24h).toBe(1);
|
expect(body.engagemang.lagade_maltider_24h).toBe(1);
|
||||||
expect(body.sakerhet.overifierade_visade).toBe(3);
|
expect(body.sakerhet.overifierade_visade).toBe(0);
|
||||||
expect(body.sakerhet.senaste_kontroll).toBeTruthy();
|
expect(body.sakerhet.senaste_kontroll).toBeTruthy();
|
||||||
expect(body.jobb.vantande).toBe(2);
|
expect(body.jobb.vantande).toBe(2);
|
||||||
expect(body.jobb.workers_ok).toBe(true);
|
expect(body.jobb.workers_ok).toBe(true);
|
||||||
|
|||||||
@@ -11,16 +11,13 @@ const RAW_PROTEIN_REQUIRING_SAFE_COOKING = new Set([
|
|||||||
"minced_mixed",
|
"minced_mixed",
|
||||||
"meatball_pork_beef",
|
"meatball_pork_beef",
|
||||||
"falukorv",
|
"falukorv",
|
||||||
"egg",
|
|
||||||
"cod",
|
"cod",
|
||||||
"salmon",
|
"salmon",
|
||||||
"shrimp",
|
"shrimp",
|
||||||
"anchovy_swedish",
|
|
||||||
"pickled_herring",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SAFE_COOKING_KEYWORDS_SV = [
|
const SAFE_COOKING_KEYWORDS_SV = [
|
||||||
/\bgenomstekt\b/i,
|
/\bgenomstek/i,
|
||||||
/\bgenomkokt\b/i,
|
/\bgenomkokt\b/i,
|
||||||
/\bgenomgrillad\b/i,
|
/\bgenomgrillad\b/i,
|
||||||
/\bgenomv\w+\b/i,
|
/\bgenomv\w+\b/i,
|
||||||
@@ -170,7 +167,7 @@ export async function processSafetyCanary(ctx: WorkerContext): Promise<SafetyCan
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.recipes.status, "published"),
|
eq(schema.recipes.status, "published"),
|
||||||
ne(schema.recipes.verificationStatus, "verified"),
|
sql`${schema.recipes.verificationStatus} IN ('unverified', 'rejected')`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const overifieradeVisade = publicUnverified[0]?.count ?? 0;
|
const overifieradeVisade = publicUnverified[0]?.count ?? 0;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Feedback-tabell för buggrapporter och önskemål (doc §2).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'feedback_type') THEN
|
||||||
|
CREATE TYPE feedback_type AS ENUM ('bug', 'onskemal');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'feedback_status') THEN
|
||||||
|
CREATE TYPE feedback_status AS ENUM ('oppen', 'pagar', 'stangd');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
typ feedback_type NOT NULL,
|
||||||
|
rubrik varchar(200) NOT NULL,
|
||||||
|
text text NOT NULL,
|
||||||
|
status feedback_status NOT NULL DEFAULT 'oppen',
|
||||||
|
plattform varchar(16),
|
||||||
|
app_version varchar(32),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_status_created_idx
|
||||||
|
ON feedback (status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_user_idx
|
||||||
|
ON feedback (user_id, created_at DESC);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Lägg till 'rejected' som recipe verification-status för food-safety/ops-canary.
|
||||||
|
ALTER TYPE recipe_verification_status ADD VALUE IF NOT EXISTS 'rejected';
|
||||||
@@ -162,6 +162,20 @@
|
|||||||
"when": 1786267200000,
|
"when": 1786267200000,
|
||||||
"tag": "0023_ops_safety_canary",
|
"tag": "0023_ops_safety_canary",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786303200000,
|
||||||
|
"tag": "0024_feedback",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786306800000,
|
||||||
|
"tag": "0025_recipe_status_rejected",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
|
|||||||
.returning({ id: schema.notifications.id });
|
.returning({ id: schema.notifications.id });
|
||||||
if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length });
|
if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length });
|
||||||
|
|
||||||
|
const feedbackDel = await db
|
||||||
|
.delete(schema.feedback)
|
||||||
|
.where(eq(schema.feedback.userId, userId))
|
||||||
|
.returning({ id: schema.feedback.id });
|
||||||
|
if (feedbackDel.length) log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length });
|
||||||
|
|
||||||
// ---- 2. Recipes ----
|
// ---- 2. Recipes ----
|
||||||
const deletedRecipes = await db
|
const deletedRecipes = await db
|
||||||
.delete(schema.recipes)
|
.delete(schema.recipes)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
|
OpsAppBlock,
|
||||||
OpsActivationBlock,
|
OpsActivationBlock,
|
||||||
OpsAiScanBlock,
|
OpsAiScanBlock,
|
||||||
OpsEngagementBlock,
|
OpsEngagementBlock,
|
||||||
@@ -7,7 +8,13 @@ import type {
|
|||||||
OpsQueueBlock,
|
OpsQueueBlock,
|
||||||
OpsSafetyBlock,
|
OpsSafetyBlock,
|
||||||
OpsSummary,
|
OpsSummary,
|
||||||
|
OpsEconomyBlock,
|
||||||
|
OpsUsersBlock,
|
||||||
|
OpsSubscriptionsBlock,
|
||||||
|
OpsStoreBlock,
|
||||||
|
OpsFeedbackBlock,
|
||||||
} from "@app/shared-types";
|
} from "@app/shared-types";
|
||||||
|
import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types";
|
||||||
import type { Database } from "./client.js";
|
import type { Database } from "./client.js";
|
||||||
import { schema } from "./index.js";
|
import { schema } from "./index.js";
|
||||||
|
|
||||||
@@ -26,6 +33,8 @@ export interface ComputeOpsSummaryOptions {
|
|||||||
/** Current daily spend in USD, from Redis budget store. */
|
/** Current daily spend in USD, from Redis budget store. */
|
||||||
dailySpendUsd?: number | null;
|
dailySpendUsd?: number | null;
|
||||||
queueSummary?: OpsQueueSummary;
|
queueSummary?: OpsQueueSummary;
|
||||||
|
/** Optional override for subscription plan prices in SEK öre per month. */
|
||||||
|
planPrices?: Partial<Record<SubscriptionPlan, number | null>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MICROCENTS_PER_USD = 100_000_000;
|
const MICROCENTS_PER_USD = 100_000_000;
|
||||||
@@ -76,7 +85,8 @@ async function aiScanBlock(
|
|||||||
AND properties->>'latencyMs' IS NOT NULL
|
AND properties->>'latencyMs' IS NOT NULL
|
||||||
`);
|
`);
|
||||||
const latencyRow = (Array.isArray(latency) ? latency[0] : latency.rows[0]) as
|
const latencyRow = (Array.isArray(latency) ? latency[0] : latency.rows[0]) as
|
||||||
{ p50: string | number | null; p95: string | number | null } | undefined;
|
| { p50: string | number | null; p95: string | number | null }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const latestErrors = await db.execute(sql`
|
const latestErrors = await db.execute(sql`
|
||||||
SELECT properties->>'errorCode' AS code, occurred_at AS tid
|
SELECT properties->>'errorCode' AS code, occurred_at AS tid
|
||||||
@@ -86,9 +96,7 @@ async function aiScanBlock(
|
|||||||
ORDER BY occurred_at DESC
|
ORDER BY occurred_at DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
`);
|
`);
|
||||||
const latestErrorsRows = (
|
const latestErrorsRows = (Array.isArray(latestErrors) ? latestErrors : latestErrors.rows) as Array<{
|
||||||
Array.isArray(latestErrors) ? latestErrors : latestErrors.rows
|
|
||||||
) as Array<{
|
|
||||||
code: string | null;
|
code: string | null;
|
||||||
tid: string | Date;
|
tid: string | Date;
|
||||||
}>;
|
}>;
|
||||||
@@ -98,10 +106,8 @@ async function aiScanBlock(
|
|||||||
FROM ai_usage_counters
|
FROM ai_usage_counters
|
||||||
WHERE month = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM')
|
WHERE month = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM')
|
||||||
`);
|
`);
|
||||||
const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as
|
const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as { total: bigint | number } | undefined;
|
||||||
{ total: bigint | number } | undefined;
|
const monthlyCostMicrocents = typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total);
|
||||||
const monthlyCostMicrocents =
|
|
||||||
typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total);
|
|
||||||
|
|
||||||
const exact24h = await db.execute(sql`
|
const exact24h = await db.execute(sql`
|
||||||
SELECT COALESCE(sum(cost_usd), 0)::float AS total
|
SELECT COALESCE(sum(cost_usd), 0)::float AS total
|
||||||
@@ -110,8 +116,7 @@ async function aiScanBlock(
|
|||||||
AND updated_at >= now() - interval '24 hours'
|
AND updated_at >= now() - interval '24 hours'
|
||||||
AND status IN ('awaiting_confirmation', 'completed')
|
AND status IN ('awaiting_confirmation', 'completed')
|
||||||
`);
|
`);
|
||||||
const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as
|
const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as { total: number } | undefined;
|
||||||
{ total: number } | undefined;
|
|
||||||
const exactCostUsd = asNumber(exact24hRow?.total);
|
const exactCostUsd = asNumber(exact24hRow?.total);
|
||||||
|
|
||||||
let cost24hMicrocents: number | null = null;
|
let cost24hMicrocents: number | null = null;
|
||||||
@@ -178,7 +183,8 @@ async function conversionWithin(
|
|||||||
(SELECT count(*) FROM converted) AS converted
|
(SELECT count(*) FROM converted) AS converted
|
||||||
`);
|
`);
|
||||||
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
||||||
{ total: number; converted: number } | undefined;
|
| { total: number; converted: number }
|
||||||
|
| undefined;
|
||||||
if (!row || asNumber(row.total) === 0) return null;
|
if (!row || asNumber(row.total) === 0) return null;
|
||||||
return asNumber(row.converted) / asNumber(row.total);
|
return asNumber(row.converted) / asNumber(row.total);
|
||||||
}
|
}
|
||||||
@@ -210,7 +216,8 @@ async function householdConversionWithin(
|
|||||||
(SELECT count(*) FROM converted) AS converted
|
(SELECT count(*) FROM converted) AS converted
|
||||||
`);
|
`);
|
||||||
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
||||||
{ total: number; converted: number } | undefined;
|
| { total: number; converted: number }
|
||||||
|
| undefined;
|
||||||
if (!row || asNumber(row.total) === 0) return null;
|
if (!row || asNumber(row.total) === 0) return null;
|
||||||
return asNumber(row.converted) / asNumber(row.total);
|
return asNumber(row.converted) / asNumber(row.total);
|
||||||
}
|
}
|
||||||
@@ -236,7 +243,8 @@ async function cohortRetention(db: Database, day: number): Promise<number | null
|
|||||||
(SELECT count(*) FROM active) AS active
|
(SELECT count(*) FROM active) AS active
|
||||||
`);
|
`);
|
||||||
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
||||||
{ total: number; active: number } | undefined;
|
| { total: number; active: number }
|
||||||
|
| undefined;
|
||||||
if (!row || asNumber(row.total) === 0) return null;
|
if (!row || asNumber(row.total) === 0) return null;
|
||||||
return asNumber(row.active) / asNumber(row.total);
|
return asNumber(row.active) / asNumber(row.total);
|
||||||
}
|
}
|
||||||
@@ -271,7 +279,8 @@ async function engagementBlock(db: Database): Promise<OpsEngagementBlock> {
|
|||||||
FROM product_analytics_events
|
FROM product_analytics_events
|
||||||
`);
|
`);
|
||||||
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
||||||
{ c24: number; c7: number; opened: number; viewed: number } | undefined;
|
| { c24: number; c7: number; opened: number; viewed: number }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const tips = await db.execute(sql`
|
const tips = await db.execute(sql`
|
||||||
SELECT count(*)::int AS n
|
SELECT count(*)::int AS n
|
||||||
@@ -298,7 +307,8 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
|
|||||||
FROM subscriptions
|
FROM subscriptions
|
||||||
`);
|
`);
|
||||||
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
||||||
{ failed: number; grace: number } | undefined;
|
| { failed: number; grace: number }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const trials = await db.execute(sql`
|
const trials = await db.execute(sql`
|
||||||
SELECT count(*)::int AS n
|
SELECT count(*)::int AS n
|
||||||
@@ -306,8 +316,7 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
|
|||||||
WHERE ends_at >= now()
|
WHERE ends_at >= now()
|
||||||
AND ends_at <= now() + interval '48 hours'
|
AND ends_at <= now() + interval '48 hours'
|
||||||
`);
|
`);
|
||||||
const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as
|
const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as { n: number } | undefined;
|
||||||
{ n: number } | undefined;
|
|
||||||
|
|
||||||
const store = await db.execute(sql`
|
const store = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -317,7 +326,8 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
|
|||||||
WHERE created_at >= now() - interval '7 days'
|
WHERE created_at >= now() - interval '7 days'
|
||||||
`);
|
`);
|
||||||
const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as
|
const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as
|
||||||
{ refunds: number; chargebacks: number } | undefined;
|
| { refunds: number; chargebacks: number }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
failed_nu: asNumber(row?.failed),
|
failed_nu: asNumber(row?.failed),
|
||||||
@@ -361,9 +371,217 @@ async function safetyBlock(db: Database): Promise<OpsSafetyBlock> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appBlock(): OpsAppBlock {
|
||||||
|
return { app: "cibello", generated_at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlanPrices(): Record<SubscriptionPlan, number | null> {
|
||||||
|
const defaults = Object.fromEntries(SUBSCRIPTION_PLANS.map((p) => [p, null])) as Record<
|
||||||
|
SubscriptionPlan,
|
||||||
|
number | null
|
||||||
|
>;
|
||||||
|
const envJson = process.env.SUBSCRIPTION_PLAN_PRICES_JSON;
|
||||||
|
if (envJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(envJson) as Record<string, number | null>;
|
||||||
|
for (const plan of SUBSCRIPTION_PLANS) {
|
||||||
|
if (parsed[plan] !== undefined) defaults[plan] = parsed[plan];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignorera trasig env-JSON; använd null för alla planer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function economyBlock(
|
||||||
|
db: Database,
|
||||||
|
planPrices: Record<SubscriptionPlan, number | null>,
|
||||||
|
): Promise<OpsEconomyBlock> {
|
||||||
|
const active = await db.execute(sql`
|
||||||
|
SELECT plan, count(*)::int AS n
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
GROUP BY plan
|
||||||
|
`);
|
||||||
|
const activeRows = (Array.isArray(active) ? active : active.rows) as Array<{
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
n: number;
|
||||||
|
}>;
|
||||||
|
let mrr: number | null = 0;
|
||||||
|
for (const row of activeRows) {
|
||||||
|
const price = planPrices[row.plan];
|
||||||
|
if (price === null) {
|
||||||
|
mrr = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
mrr = (mrr ?? 0) + price * row.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revenueFor(hours: number): Promise<number | null> {
|
||||||
|
const events = await db.execute(sql`
|
||||||
|
SELECT properties
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'subscription_started'
|
||||||
|
AND occurred_at >= now() - make_interval(hours => ${hours})
|
||||||
|
`);
|
||||||
|
const rows = (Array.isArray(events) ? events : events.rows) as Array<{
|
||||||
|
properties: { priceOer?: number; plan?: SubscriptionPlan } | null;
|
||||||
|
}>;
|
||||||
|
let total: number | null = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const props = row.properties ?? {};
|
||||||
|
const price =
|
||||||
|
typeof props.priceOer === "number" ? props.priceOer : planPrices[props.plan ?? "free"];
|
||||||
|
if (price === null) {
|
||||||
|
total = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total = (total ?? 0) + price;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [intakt24h, intakt7d] = await Promise.all([revenueFor(24), revenueFor(24 * 7)]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mrr,
|
||||||
|
intakt_24h: intakt24h,
|
||||||
|
intakt_7d: intakt7d,
|
||||||
|
valuta: "SEK",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usersBlock(db: Database): Promise<OpsUsersBlock> {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(mins => 5))::int AS aktiva_nu,
|
||||||
|
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(hours => 24))::int AS dau,
|
||||||
|
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(days => 30))::int AS mau,
|
||||||
|
count(*) FILTER (WHERE event_name = 'account_created' AND occurred_at >= now() - make_interval(hours => 24))::int AS nya_24h,
|
||||||
|
count(*) FILTER (WHERE event_name = 'account_created' AND occurred_at >= now() - make_interval(days => 7))::int AS nya_7d
|
||||||
|
FROM product_analytics_events
|
||||||
|
`);
|
||||||
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as {
|
||||||
|
aktiva_nu: number;
|
||||||
|
dau: number;
|
||||||
|
mau: number;
|
||||||
|
nya_24h: number;
|
||||||
|
nya_7d: number;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
aktiva_nu: asNumber(row.aktiva_nu),
|
||||||
|
dau: asNumber(row.dau),
|
||||||
|
mau: asNumber(row.mau),
|
||||||
|
nya_24h: asNumber(row.nya_24h),
|
||||||
|
nya_7d: asNumber(row.nya_7d),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscriptionsBlock(db: Database): Promise<OpsSubscriptionsBlock> {
|
||||||
|
const [trials, paying] = await Promise.all([
|
||||||
|
db.execute(sql`SELECT count(*)::int AS n FROM trials WHERE ends_at >= now()`),
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT count(*)::int AS n
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status = 'active' AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
const trialAktiva = asNumber((Array.isArray(trials) ? trials[0] : trials.rows[0]).n);
|
||||||
|
const betalande = asNumber((Array.isArray(paying) ? paying[0] : paying.rows[0]).n);
|
||||||
|
|
||||||
|
const trialConv24h = await db.execute(sql`
|
||||||
|
SELECT count(DISTINCT t.user_id)::int AS n
|
||||||
|
FROM product_analytics_events t
|
||||||
|
INNER JOIN product_analytics_events s ON s.user_id = t.user_id
|
||||||
|
WHERE t.event_name = 'trial_started'
|
||||||
|
AND s.event_name = 'subscription_started'
|
||||||
|
AND s.occurred_at >= now() - make_interval(hours => 24)
|
||||||
|
AND s.occurred_at >= t.occurred_at
|
||||||
|
`);
|
||||||
|
const trialConv7d = await db.execute(sql`
|
||||||
|
SELECT count(DISTINCT t.user_id)::int AS n
|
||||||
|
FROM product_analytics_events t
|
||||||
|
INNER JOIN product_analytics_events s ON s.user_id = t.user_id
|
||||||
|
WHERE t.event_name = 'trial_started'
|
||||||
|
AND s.event_name = 'subscription_started'
|
||||||
|
AND s.occurred_at >= now() - make_interval(days => 7)
|
||||||
|
AND s.occurred_at >= t.occurred_at
|
||||||
|
`);
|
||||||
|
|
||||||
|
const konv30d = await conversionWithin(db, "trial_started", "subscription_started", 30, 30 * 24);
|
||||||
|
|
||||||
|
const avslutade = await db.execute(sql`
|
||||||
|
SELECT count(*)::int AS n
|
||||||
|
FROM subscription_events
|
||||||
|
WHERE event_type IN ('expired', 'cancelled')
|
||||||
|
AND created_at >= now() - make_interval(hours => 24)
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trial_aktiva: trialAktiva,
|
||||||
|
trial_konverterade_24h: asNumber((Array.isArray(trialConv24h) ? trialConv24h[0] : trialConv24h.rows[0]).n),
|
||||||
|
trial_konverterade_7d: asNumber((Array.isArray(trialConv7d) ? trialConv7d[0] : trialConv7d.rows[0]).n),
|
||||||
|
konverteringsgrad_30d: roundRate(konv30d),
|
||||||
|
betalande,
|
||||||
|
avslutade_24h: asNumber((Array.isArray(avslutade) ? avslutade[0] : avslutade.rows[0]).n),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeBlock(): OpsStoreBlock {
|
||||||
|
return { butik: null, all_fields_phase_2: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function feedbackBlock(db: Database): Promise<OpsFeedbackBlock> {
|
||||||
|
const counts = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE status = 'oppen')::int AS oppna,
|
||||||
|
count(*) FILTER (WHERE created_at >= now() - make_interval(hours => 24))::int AS nya_24h
|
||||||
|
FROM feedback
|
||||||
|
`);
|
||||||
|
const countsRow = (Array.isArray(counts) ? counts[0] : counts.rows[0]) as {
|
||||||
|
oppna: number;
|
||||||
|
nya_24h: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latest = await db
|
||||||
|
.select({
|
||||||
|
rubrik: schema.feedback.rubrik,
|
||||||
|
typ: schema.feedback.typ,
|
||||||
|
status: schema.feedback.status,
|
||||||
|
createdAt: schema.feedback.createdAt,
|
||||||
|
})
|
||||||
|
.from(schema.feedback)
|
||||||
|
.orderBy(sql`${schema.feedback.createdAt} DESC`)
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
oppna: asNumber(countsRow.oppna),
|
||||||
|
nya_24h: asNumber(countsRow.nya_24h),
|
||||||
|
senaste:
|
||||||
|
latest.length > 0
|
||||||
|
? latest.map((f) => ({
|
||||||
|
rubrik: f.rubrik,
|
||||||
|
typ: f.typ,
|
||||||
|
status: f.status,
|
||||||
|
created_at: f.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
|
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
|
||||||
const { db, budgetUsd, dailySpendUsd, queueSummary } = options;
|
const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options;
|
||||||
const [ai, activation, engagement, payment, safety] = await Promise.all([
|
const planPrices = { ...getPlanPrices(), ...(planPricesOverride ?? {}) };
|
||||||
|
const [app, economy, users, subscriptions, butik, feedbackData, ai, activation, engagement, payment, safety] =
|
||||||
|
await Promise.all([
|
||||||
|
Promise.resolve(appBlock()),
|
||||||
|
economyBlock(db, planPrices),
|
||||||
|
usersBlock(db),
|
||||||
|
subscriptionsBlock(db),
|
||||||
|
Promise.resolve(storeBlock()),
|
||||||
|
feedbackBlock(db),
|
||||||
aiScanBlock(db, budgetUsd, dailySpendUsd),
|
aiScanBlock(db, budgetUsd, dailySpendUsd),
|
||||||
activationBlock(db),
|
activationBlock(db),
|
||||||
engagementBlock(db),
|
engagementBlock(db),
|
||||||
@@ -372,6 +590,12 @@ export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Prom
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
app,
|
||||||
|
ekonomi: economy,
|
||||||
|
anvandare: users,
|
||||||
|
prenumerationer: subscriptions,
|
||||||
|
butik,
|
||||||
|
feedback: feedbackData,
|
||||||
ai_scan: ai,
|
ai_scan: ai,
|
||||||
aktivering: activation,
|
aktivering: activation,
|
||||||
engagemang: engagement,
|
engagemang: engagement,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { index, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
|
||||||
|
import { createdAt } from "./_shared.js";
|
||||||
|
import { users } from "./users.js";
|
||||||
|
|
||||||
|
export const feedbackTypeEnum = pgEnum("feedback_type", ["bug", "onskemal"]);
|
||||||
|
export const feedbackStatusEnum = pgEnum("feedback_status", ["oppen", "pagar", "stangd"]);
|
||||||
|
|
||||||
|
/** Användarfeedback: buggrapporter och önskemål (doc §2). */
|
||||||
|
export const feedback = pgTable(
|
||||||
|
"feedback",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
typ: feedbackTypeEnum("typ").notNull(),
|
||||||
|
rubrik: varchar("rubrik", { length: 200 }).notNull(),
|
||||||
|
/** Full text – visas aldrig i ops-sammanfattningen. */
|
||||||
|
text: text("text").notNull(),
|
||||||
|
status: feedbackStatusEnum("status").notNull().default("oppen"),
|
||||||
|
plattform: varchar("plattform", { length: 16 }),
|
||||||
|
appVersion: varchar("app_version", { length: 32 }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("feedback_status_created_idx").on(t.status, t.createdAt),
|
||||||
|
index("feedback_user_idx").on(t.userId, t.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -18,6 +18,7 @@ export * from "./memory.js";
|
|||||||
export * from "./seasons.js";
|
export * from "./seasons.js";
|
||||||
export * from "./subscriptions.js";
|
export * from "./subscriptions.js";
|
||||||
export * from "./analytics.js";
|
export * from "./analytics.js";
|
||||||
|
export * from "./feedback.js";
|
||||||
export * from "./ops.js";
|
export * from "./ops.js";
|
||||||
export * from "./platform.js";
|
export * from "./platform.js";
|
||||||
export * from "./releaseGates.js";
|
export * from "./releaseGates.js";
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
text: "Sänk värmen, tillsätt vitlöken och stek 30 sekunder utan att den tar färg.",
|
text: "Sänk värmen, tillsätt vitlöken och stek 30 sekunder utan att den tar färg.",
|
||||||
timerSeconds: 30,
|
timerSeconds: 30,
|
||||||
},
|
},
|
||||||
{ text: "Häll i grädden, låt sjuda ihop 3–4 minuter och rör ner osten.", timerSeconds: 210 },
|
{ text: "Häll i grädden, låt sjuda ihop 3–4 minuter och rör ner osten. Se till att korven är genomstekt.", timerSeconds: 210 },
|
||||||
{
|
{
|
||||||
text: "Vänd ner spenaten tills den precis sjunker ihop. Smaka av med salt, peppar och ev. chiliflakes.",
|
text: "Vänd ner spenaten tills den precis sjunker ihop. Smaka av med salt, peppar och ev. chiliflakes.",
|
||||||
},
|
},
|
||||||
@@ -318,7 +318,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
{ text: "Bryn färsen i olja på hög värme tills den fått färg." },
|
{ text: "Bryn färsen i olja på hög värme tills den fått färg." },
|
||||||
{ text: "Rör i tacokryddan och 1 dl vatten, låt puttra 5 minuter.", timerSeconds: 300 },
|
{ text: "Rör i tacokryddan och 1 dl vatten, låt puttra 5 minuter tills färsen är genomstekt.", timerSeconds: 300 },
|
||||||
{ text: "Tärna tomat, strimla sallad och riv osten. Ställ fram allt i skålar." },
|
{ text: "Tärna tomat, strimla sallad och riv osten. Ställ fram allt i skålar." },
|
||||||
{ text: "Värm tortillabröden enligt paketet." },
|
{ text: "Värm tortillabröden enligt paketet." },
|
||||||
{ text: "Låt alla bygga sina egna tacos vid bordet." },
|
{ text: "Låt alla bygga sina egna tacos vid bordet." },
|
||||||
@@ -413,7 +413,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
text: "Lägg laxen i en smord form, salta, peppra och toppa med citronskivor och halva dillen.",
|
text: "Lägg laxen i en smord form, salta, peppra och toppa med citronskivor och halva dillen.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Baka i ugnen 15–18 minuter tills laxen precis går att dela i mitten.",
|
text: "Baka i ugnen 15–18 minuter tills laxen är genomstekt och går att dela i mitten.",
|
||||||
timerSeconds: 960,
|
timerSeconds: 960,
|
||||||
temperatureC: 200,
|
temperatureC: 200,
|
||||||
tip: "Innertemperatur 52–55 °C ger saftig lax.",
|
tip: "Innertemperatur 52–55 °C ger saftig lax.",
|
||||||
@@ -506,7 +506,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
{ text: "Koka riset enligt paketets anvisning.", timerSeconds: 720 },
|
{ text: "Koka riset enligt paketets anvisning.", timerSeconds: 720 },
|
||||||
{ text: "Stek korvstrimlor och lök i olja tills de fått lite färg." },
|
{ text: "Stek korvstrimlor och lök i olja tills de fått lite färg." },
|
||||||
{ text: "Rör i tomatpuré och paprikapulver, fräs 1 minut.", timerSeconds: 60 },
|
{ text: "Rör i tomatpuré och paprikapulver, fräs 1 minut.", timerSeconds: 60 },
|
||||||
{ text: "Häll i grädden och senapen, låt sjuda 5 minuter.", timerSeconds: 300 },
|
{ text: "Häll i grädden och senapen, låt sjuda 5 minuter. Se till att korven är genomstekt.", timerSeconds: 300 },
|
||||||
{ text: "Smaka av med svartpeppar och servera med riset." },
|
{ text: "Smaka av med svartpeppar och servera med riset." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -588,7 +588,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
steps: [
|
steps: [
|
||||||
{ text: "Koka nudlarna enligt paketet, skölj i kallt vatten och låt rinna av." },
|
{ text: "Koka nudlarna enligt paketet, skölj i kallt vatten och låt rinna av." },
|
||||||
{
|
{
|
||||||
text: "Hetta upp oljan i wok. Woka kycklingen tills den fått färg, 3–4 minuter.",
|
text: "Hetta upp oljan i wok. Woka kycklingen tills den fått färg och är genomstekt, 3–4 minuter.",
|
||||||
timerSeconds: 210,
|
timerSeconds: 210,
|
||||||
},
|
},
|
||||||
{ text: "Tillsätt grönsaker, vitlök och ingefära, woka 3 minuter till.", timerSeconds: 180 },
|
{ text: "Tillsätt grönsaker, vitlök och ingefära, woka 3 minuter till.", timerSeconds: 180 },
|
||||||
@@ -689,7 +689,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
},
|
},
|
||||||
{ text: "Rör i tomatpuré och alla kryddor, fräs 1 minut.", timerSeconds: 60 },
|
{ text: "Rör i tomatpuré och alla kryddor, fräs 1 minut.", timerSeconds: 60 },
|
||||||
{
|
{
|
||||||
text: "Tillsätt krossade tomater och 2 dl vatten. Sjud under lock 30 minuter.",
|
text: "Tillsätt krossade tomater och 2 dl vatten. Sjud under lock 30 minuter. Se till att färsen är genomstekt.",
|
||||||
timerSeconds: 1800,
|
timerSeconds: 1800,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -732,7 +732,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
steps: [
|
steps: [
|
||||||
{ text: "Koka riset. Ånga eller koka broccolin de sista 4 minuterna.", timerSeconds: 720 },
|
{ text: "Koka riset. Ånga eller koka broccolin de sista 4 minuterna.", timerSeconds: 720 },
|
||||||
{
|
{
|
||||||
text: "Stek laxen i olja med skinnsidan ner 3–4 minuter, vänd och stek 2 minuter till.",
|
text: "Stek laxen i olja med skinnsidan ner 3–4 minuter, vänd och stek 2 minuter till tills laxen är genomstekt.",
|
||||||
timerSeconds: 330,
|
timerSeconds: 330,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -783,7 +783,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
},
|
},
|
||||||
{ text: "Rör i tomatpuré, riven morot och oregano." },
|
{ text: "Rör i tomatpuré, riven morot och oregano." },
|
||||||
{
|
{
|
||||||
text: "Tillsätt krossade tomater och 1 dl vatten. Låt sjuda minst 20 minuter.",
|
text: "Tillsätt krossade tomater och 1 dl vatten. Låt sjuda minst 20 minuter. Se till att färsen är genomstekt.",
|
||||||
timerSeconds: 1200,
|
timerSeconds: 1200,
|
||||||
tip: "Längre puttertid = rundare smak.",
|
tip: "Längre puttertid = rundare smak.",
|
||||||
},
|
},
|
||||||
@@ -831,7 +831,7 @@ export const SEED_RECIPES: SeedRecipe[] = [
|
|||||||
{
|
{
|
||||||
text: "Stek potatistärningarna i hälften av smöret på hög värme tills gyllene och krispiga.",
|
text: "Stek potatistärningarna i hälften av smöret på hög värme tills gyllene och krispiga.",
|
||||||
},
|
},
|
||||||
{ text: "Tillsätt lök och korv och stek tills allt fått fin färg." },
|
{ text: "Tillsätt lök och korv och stek tills allt fått fin färg. Se till att korven är genomstekt." },
|
||||||
{ text: "Stek äggen i resten av smöret." },
|
{ text: "Stek äggen i resten av smöret." },
|
||||||
{ text: "Salta, peppra och servera pytten med stekt ägg och gärna rödbetor." },
|
{ text: "Salta, peppra och servera pytten med stekt ägg och gärna rödbetor." },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export const RECIPE_VERIFICATION_STATUSES = [
|
|||||||
"community",
|
"community",
|
||||||
"verified",
|
"verified",
|
||||||
"editorial",
|
"editorial",
|
||||||
|
"rejected",
|
||||||
] as const;
|
] as const;
|
||||||
export type RecipeVerificationStatus = (typeof RECIPE_VERIFICATION_STATUSES)[number];
|
export type RecipeVerificationStatus = (typeof RECIPE_VERIFICATION_STATUSES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,50 @@
|
|||||||
|
export interface OpsAppBlock {
|
||||||
|
app: string;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsEconomyBlock {
|
||||||
|
mrr: number | null;
|
||||||
|
intakt_24h: number | null;
|
||||||
|
intakt_7d: number | null;
|
||||||
|
valuta: "SEK";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsUsersBlock {
|
||||||
|
aktiva_nu: number;
|
||||||
|
dau: number;
|
||||||
|
mau: number;
|
||||||
|
nya_24h: number;
|
||||||
|
nya_7d: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsSubscriptionsBlock {
|
||||||
|
trial_aktiva: number;
|
||||||
|
trial_konverterade_24h: number;
|
||||||
|
trial_konverterade_7d: number;
|
||||||
|
konverteringsgrad_30d: number | null;
|
||||||
|
betalande: number;
|
||||||
|
avslutade_24h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsStoreBlock {
|
||||||
|
butik: null;
|
||||||
|
all_fields_phase_2: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsFeedbackItem {
|
||||||
|
rubrik: string;
|
||||||
|
typ: "bug" | "onskemal";
|
||||||
|
status: "oppen" | "pagar" | "stangd";
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsFeedbackBlock {
|
||||||
|
oppna: number;
|
||||||
|
nya_24h: number;
|
||||||
|
senaste: OpsFeedbackItem[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpsAiScanBlock {
|
export interface OpsAiScanBlock {
|
||||||
scans_24h: number;
|
scans_24h: number;
|
||||||
scans_7d: number;
|
scans_7d: number;
|
||||||
@@ -54,6 +101,12 @@ export interface OpsSafetyBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OpsSummary {
|
export interface OpsSummary {
|
||||||
|
app: OpsAppBlock;
|
||||||
|
ekonomi: OpsEconomyBlock;
|
||||||
|
anvandare: OpsUsersBlock;
|
||||||
|
prenumerationer: OpsSubscriptionsBlock;
|
||||||
|
butik: OpsStoreBlock;
|
||||||
|
feedback: OpsFeedbackBlock;
|
||||||
ai_scan: OpsAiScanBlock;
|
ai_scan: OpsAiScanBlock;
|
||||||
aktivering: OpsActivationBlock;
|
aktivering: OpsActivationBlock;
|
||||||
engagemang: OpsEngagementBlock;
|
engagemang: OpsEngagementBlock;
|
||||||
|
|||||||
Reference in New Issue
Block a user