feat(gdpr): fullständig raderingsflöde med pseudonym-retention, residual-test och hushållshantering

- Implementerar eraseUser() i packages/database/src/gdpr-erasure.ts som
  raderar/anonymiserar/pseudonymiserar alla personliga tabeller.
- DELETE /v1/me orkestrerar nu plan + transaktion + lagringsrensning.
- Enpersonshushåll raderas automatiskt; publika recept anonymiseras;
  draft/private-recept raderas.
- Finansiella poster (subscriptions/store_transactions/subscription_events)
  behålls under retention med slumpmässig pseudonym från
  gdpr_retention_pseudonyms.
- audit_logs/domain_events anonymiseras; kvitton i kvarvarande hushåll
  strippas på bild och OCR-text.
- Lägger till permanent residual-test (me.residual.test.ts) med
  schema-diff-assertion mot information_schema.
- Uppdaterar docs/23 och docs/29 med implementerad mekanism.
- Migration 0021 för gdpr_retention_pseudonyms och nullable
  cooking_sessions.started_by_user_id.
This commit is contained in:
Sven (AAMOS AI)
2026-08-08 05:45:49 +07:00
parent 1d78dbcad0
commit ddd6b736ba
14 changed files with 11183 additions and 79 deletions
+432
View File
@@ -0,0 +1,432 @@
import { randomUUID } from "node:crypto";
import { and, eq, getTableName, inArray, ne, or, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { deleteUserAnalyticsEvents } from "./analytics-gdpr.js";
import * as schema from "./schema/index.js";
type Database = NodePgDatabase<typeof schema>;
export type ErasureLogEntry = {
table: string;
action: "delete" | "anonymize" | "pseudonymize" | "retain_pseudonym";
count: number;
};
export type ErasurePlan = {
/** S3 URLs/keys that must be purged from object storage. */
imageReferences: string[];
/** Households that will be hard-deleted because the user was the sole member. */
householdsToDelete: string[];
};
const RETENTION_YEARS = 7;
/** Build the plan without mutating anything. */
export async function buildErasurePlan(db: Database, userId: string): Promise<ErasurePlan> {
const imageReferences: string[] = [];
// --- Personal images (always deleted) ---
const correctionImages = await db
.select({ key: schema.aiCorrections.imageS3Key })
.from(schema.aiCorrections)
.where(eq(schema.aiCorrections.userId, userId));
for (const row of correctionImages) {
if (row.key) imageReferences.push(row.key);
}
const bankImages = await db
.select({ key: schema.aiTrainingBank.imageS3Key })
.from(schema.aiTrainingBank)
.innerJoin(schema.aiCorrections, eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id))
.where(eq(schema.aiCorrections.userId, userId));
for (const row of bankImages) {
if (row.key) imageReferences.push(row.key);
}
const scanRows = await db
.select({ keys: schema.scanJobs.s3Keys })
.from(schema.scanJobs)
.where(eq(schema.scanJobs.userId, userId));
for (const row of scanRows) {
for (const key of row.keys ?? []) {
if (key) imageReferences.push(key);
}
}
const memoryPhotos = await db
.select({ url: schema.foodMemories.photoUrl })
.from(schema.foodMemories)
.where(eq(schema.foodMemories.userId, userId));
for (const row of memoryPhotos) {
if (row.url) imageReferences.push(row.url);
}
// --- Households ---
const householdRows = await db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, userId));
const householdsToDelete: string[] = [];
for (const { householdId } of householdRows) {
const memberCount = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
if (Number(memberCount[0]?.count ?? 0) <= 1) {
householdsToDelete.push(householdId);
}
}
// Images tied to households we are about to delete.
if (householdsToDelete.length > 0) {
const receiptImages = await db
.select({ url: schema.receipts.imageUrl })
.from(schema.receipts)
.where(inArray(schema.receipts.householdId, householdsToDelete));
for (const row of receiptImages) {
if (row.url) imageReferences.push(row.url);
}
const householdMemoryPhotos = await db
.select({ url: schema.foodMemories.photoUrl })
.from(schema.foodMemories)
.where(inArray(schema.foodMemories.householdId, householdsToDelete));
for (const row of householdMemoryPhotos) {
if (row.url) imageReferences.push(row.url);
}
}
// --- Draft/private recipe images (public recipes stay but are anonymized) ---
const draftRecipeImages = await db
.select({ urls: schema.recipes.imageUrls })
.from(schema.recipes)
.where(
and(
eq(schema.recipes.creatorUserId, userId),
ne(schema.recipes.status, "published"),
),
);
for (const row of draftRecipeImages) {
for (const url of row.urls ?? []) {
if (url) imageReferences.push(url);
}
}
return {
imageReferences: [...new Set(imageReferences)],
householdsToDelete,
};
}
export async function eraseUser(db: Database, userId: string): Promise<ErasureLogEntry[]> {
const plan = await buildErasurePlan(db, userId);
const log: ErasureLogEntry[] = [];
// ---- 1. Auth / tokens / consents / idempotency / push / notifications (hard delete) ----
const credDel = await db
.delete(schema.userCredentials)
.where(eq(schema.userCredentials.userId, userId))
.returning({ userId: schema.userCredentials.userId });
if (credDel.length) log.push({ table: getTableName(schema.userCredentials), action: "delete", count: credDel.length });
const adminTotpDel = await db
.delete(schema.adminTotp)
.where(eq(schema.adminTotp.userId, userId))
.returning({ userId: schema.adminTotp.userId });
if (adminTotpDel.length) log.push({ table: getTableName(schema.adminTotp), action: "delete", count: adminTotpDel.length });
const emailTokensDel = await db
.delete(schema.emailVerificationTokens)
.where(eq(schema.emailVerificationTokens.userId, userId))
.returning({ id: schema.emailVerificationTokens.id });
if (emailTokensDel.length) log.push({ table: getTableName(schema.emailVerificationTokens), action: "delete", count: emailTokensDel.length });
const pwTokensDel = await db
.delete(schema.passwordResetTokens)
.where(eq(schema.passwordResetTokens.userId, userId))
.returning({ id: schema.passwordResetTokens.id });
if (pwTokensDel.length) log.push({ table: getTableName(schema.passwordResetTokens), action: "delete", count: pwTokensDel.length });
const refreshDel = await db
.delete(schema.refreshTokens)
.where(eq(schema.refreshTokens.userId, userId))
.returning({ id: schema.refreshTokens.id });
if (refreshDel.length) log.push({ table: getTableName(schema.refreshTokens), action: "delete", count: refreshDel.length });
const consentsDel = await db
.delete(schema.userConsents)
.where(eq(schema.userConsents.userId, userId))
.returning({ userId: schema.userConsents.userId });
if (consentsDel.length) log.push({ table: getTableName(schema.userConsents), action: "delete", count: consentsDel.length });
const idemDel = await db
.delete(schema.idempotencyKeys)
.where(eq(schema.idempotencyKeys.userId, userId))
.returning({ key: schema.idempotencyKeys.key });
if (idemDel.length) log.push({ table: getTableName(schema.idempotencyKeys), action: "delete", count: idemDel.length });
const pushDel = await db
.delete(schema.pushTokens)
.where(eq(schema.pushTokens.userId, userId))
.returning({ token: schema.pushTokens.token });
if (pushDel.length) log.push({ table: getTableName(schema.pushTokens), action: "delete", count: pushDel.length });
const notifDel = await db
.delete(schema.notifications)
.where(eq(schema.notifications.userId, userId))
.returning({ id: schema.notifications.id });
if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length });
// ---- 2. Recipes ----
const deletedRecipes = await db
.delete(schema.recipes)
.where(
and(
eq(schema.recipes.creatorUserId, userId),
ne(schema.recipes.status, "published"),
),
)
.returning({ id: schema.recipes.id });
if (deletedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "delete", count: deletedRecipes.length });
const anonymizedRecipes = await db
.update(schema.recipes)
.set({ creatorUserId: null, creatorDisplayName: null, updatedAt: new Date() })
.where(
and(
eq(schema.recipes.creatorUserId, userId),
eq(schema.recipes.status, "published"),
),
)
.returning({ id: schema.recipes.id });
if (anonymizedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "anonymize", count: anonymizedRecipes.length });
const ratingsDel = await db
.delete(schema.recipeRatings)
.where(eq(schema.recipeRatings.userId, userId))
.returning({ id: schema.recipeRatings.id });
if (ratingsDel.length) log.push({ table: getTableName(schema.recipeRatings), action: "delete", count: ratingsDel.length });
const favoritesDel = await db
.delete(schema.recipeFavorites)
.where(eq(schema.recipeFavorites.userId, userId))
.returning({ recipeId: schema.recipeFavorites.recipeId });
if (favoritesDel.length) log.push({ table: getTableName(schema.recipeFavorites), action: "delete", count: favoritesDel.length });
const cooksDel = await db
.delete(schema.recipeCooks)
.where(eq(schema.recipeCooks.userId, userId))
.returning({ id: schema.recipeCooks.id });
if (cooksDel.length) log.push({ table: getTableName(schema.recipeCooks), action: "delete", count: cooksDel.length });
const creatorStatsDel = await db
.delete(schema.creatorStats)
.where(eq(schema.creatorStats.userId, userId))
.returning({ userId: schema.creatorStats.userId });
if (creatorStatsDel.length) log.push({ table: getTableName(schema.creatorStats), action: "delete", count: creatorStatsDel.length });
const followsDel = await db
.delete(schema.creatorFollows)
.where(
or(eq(schema.creatorFollows.followerUserId, userId), eq(schema.creatorFollows.creatorUserId, userId)),
)
.returning({ followerUserId: schema.creatorFollows.followerUserId });
if (followsDel.length) log.push({ table: getTableName(schema.creatorFollows), action: "delete", count: followsDel.length });
// ---- 3. Personal content ----
const mealsDel = await db
.delete(schema.meals)
.where(eq(schema.meals.userId, userId))
.returning({ id: schema.meals.id });
if (mealsDel.length) log.push({ table: getTableName(schema.meals), action: "delete", count: mealsDel.length });
const foodMemDel = await db
.delete(schema.foodMemories)
.where(eq(schema.foodMemories.userId, userId))
.returning({ id: schema.foodMemories.id });
if (foodMemDel.length) log.push({ table: getTableName(schema.foodMemories), action: "delete", count: foodMemDel.length });
const tasteDel = await db
.delete(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId))
.returning({ id: schema.tasteSignals.id });
if (tasteDel.length) log.push({ table: getTableName(schema.tasteSignals), action: "delete", count: tasteDel.length });
const memoryDel = await db
.delete(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId))
.returning({ id: schema.memoryItems.id });
if (memoryDel.length) log.push({ table: getTableName(schema.memoryItems), action: "delete", count: memoryDel.length });
// ---- 4. AI / scans / training ----
const aiCorrectionsDel = await db
.delete(schema.aiCorrections)
.where(eq(schema.aiCorrections.userId, userId))
.returning({ id: schema.aiCorrections.id });
if (aiCorrectionsDel.length) log.push({ table: getTableName(schema.aiCorrections), action: "delete", count: aiCorrectionsDel.length });
const scanJobsDel = await db
.delete(schema.scanJobs)
.where(eq(schema.scanJobs.userId, userId))
.returning({ id: schema.scanJobs.id });
if (scanJobsDel.length) log.push({ table: getTableName(schema.scanJobs), action: "delete", count: scanJobsDel.length });
const aiUsageDel = await db
.delete(schema.aiUsageCounters)
.where(eq(schema.aiUsageCounters.userId, userId))
.returning({ userId: schema.aiUsageCounters.userId });
if (aiUsageDel.length) log.push({ table: getTableName(schema.aiUsageCounters), action: "delete", count: aiUsageDel.length });
const trialsDel = await db
.delete(schema.trials)
.where(eq(schema.trials.userId, userId))
.returning({ userId: schema.trials.userId });
if (trialsDel.length) log.push({ table: getTableName(schema.trials), action: "delete", count: trialsDel.length });
// ---- 5. Financial / subscriptions (pseudonymize) ----
const subCountRow = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.subscriptions)
.where(eq(schema.subscriptions.userId, userId));
const txCountRow = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.storeTransactions)
.where(eq(schema.storeTransactions.userId, userId));
const hasFinancialRecords = Number(subCountRow[0]?.count ?? 0) > 0 || Number(txCountRow[0]?.count ?? 0) > 0;
if (hasFinancialRecords) {
const pseudonym = randomUUID();
const retentionUntil = new Date();
retentionUntil.setFullYear(retentionUntil.getFullYear() + RETENTION_YEARS);
await db.insert(schema.gdprRetentionPseudonyms).values({
userId,
pseudonym,
retentionUntil,
});
const subPseud = await db
.update(schema.subscriptions)
.set({ userId: null, pseudonym, householdId: null, updatedAt: new Date() })
.where(eq(schema.subscriptions.userId, userId))
.returning({ id: schema.subscriptions.id });
if (subPseud.length) log.push({ table: getTableName(schema.subscriptions), action: "pseudonymize", count: subPseud.length });
const storePseud = await db
.update(schema.storeTransactions)
.set({
userId: null,
pseudonym,
rawPayload: sql`jsonb_strip_nulls(jsonb_build_object('transaction_id', ${schema.storeTransactions.transactionId}, 'product_id', ${schema.storeTransactions.productId}, 'provider', ${schema.storeTransactions.provider}))`,
})
.where(eq(schema.storeTransactions.userId, userId))
.returning({ id: schema.storeTransactions.id });
if (storePseud.length) log.push({ table: getTableName(schema.storeTransactions), action: "pseudonymize", count: storePseud.length });
const eventPseud = await db
.update(schema.subscriptionEvents)
.set({ userId: null, pseudonym, payload: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.subscriptionEvents.userId, userId))
.returning({ id: schema.subscriptionEvents.id });
if (eventPseud.length) log.push({ table: getTableName(schema.subscriptionEvents), action: "pseudonymize", count: eventPseud.length });
}
// ---- 6. Audit logs (anonymize actor, keep for retention) ----
const auditAnon = await db
.update(schema.auditLogs)
.set({ actorUserId: null, ip: null, metadata: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.auditLogs.actorUserId, userId))
.returning({ id: schema.auditLogs.id });
if (auditAnon.length) log.push({ table: getTableName(schema.auditLogs), action: "anonymize", count: auditAnon.length });
// ---- 7. Domain events (anonymize) ----
const domainAnon = await db
.update(schema.domainEvents)
.set({ userId: null, payload: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.domainEvents.userId, userId))
.returning({ id: schema.domainEvents.id });
if (domainAnon.length) log.push({ table: getTableName(schema.domainEvents), action: "anonymize", count: domainAnon.length });
// ---- 8. Households ----
// Remove memberships first; remember surviving households for receipt anonymization.
const survivingHouseholds = await db
.delete(schema.householdMembers)
.where(eq(schema.householdMembers.userId, userId))
.returning({ householdId: schema.householdMembers.householdId });
if (plan.householdsToDelete.length > 0) {
const hhDel = await db
.delete(schema.households)
.where(inArray(schema.households.id, plan.householdsToDelete))
.returning({ id: schema.households.id });
if (hhDel.length) log.push({ table: getTableName(schema.households), action: "delete", count: hhDel.length });
}
const survivingHouseholdIds = survivingHouseholds
.map((m) => m.householdId)
.filter((id) => !plan.householdsToDelete.includes(id));
if (survivingHouseholdIds.length > 0) {
const receiptAnon = await db
.update(schema.receipts)
.set({ imageUrl: null })
.where(inArray(schema.receipts.householdId, survivingHouseholdIds))
.returning({ id: schema.receipts.id });
if (receiptAnon.length) {
log.push({ table: getTableName(schema.receipts), action: "anonymize", count: receiptAnon.length });
await db
.update(schema.receiptLines)
.set({ rawText: "" })
.where(inArray(schema.receiptLines.receiptId, receiptAnon.map((r) => r.id)));
}
// Anonymize cooking sessions started by user in surviving households.
const cookingAnon = await db
.update(schema.cookingSessions)
.set({ startedByUserId: null })
.where(eq(schema.cookingSessions.startedByUserId, userId))
.returning({ id: schema.cookingSessions.id });
if (cookingAnon.length) log.push({ table: getTableName(schema.cookingSessions), action: "anonymize", count: cookingAnon.length });
// Anonymize household-level records that still reference the user.
const conflictAnon = await db
.update(schema.inventoryConflicts)
.set({ resolvedByUserId: null })
.where(eq(schema.inventoryConflicts.resolvedByUserId, userId))
.returning({ id: schema.inventoryConflicts.id });
if (conflictAnon.length) log.push({ table: getTableName(schema.inventoryConflicts), action: "anonymize", count: conflictAnon.length });
const transactionAnon = await db
.update(schema.inventoryTransactions)
.set({ actorUserId: null })
.where(eq(schema.inventoryTransactions.actorUserId, userId))
.returning({ id: schema.inventoryTransactions.id });
if (transactionAnon.length) log.push({ table: getTableName(schema.inventoryTransactions), action: "anonymize", count: transactionAnon.length });
const mealBoxAnon = await db
.update(schema.mealBoxes)
.set({ reservedForUserId: null })
.where(eq(schema.mealBoxes.reservedForUserId, userId))
.returning({ id: schema.mealBoxes.id });
if (mealBoxAnon.length) log.push({ table: getTableName(schema.mealBoxes), action: "anonymize", count: mealBoxAnon.length });
const shoppingAnon = await db
.update(schema.shoppingListItems)
.set({ addedByUserId: null })
.where(eq(schema.shoppingListItems.addedByUserId, userId))
.returning({ id: schema.shoppingListItems.id });
if (shoppingAnon.length) log.push({ table: getTableName(schema.shoppingListItems), action: "anonymize", count: shoppingAnon.length });
}
// ---- 9. Analytics ----
const analyticsDel = await deleteUserAnalyticsEvents(db, userId);
if (analyticsDel > 0) log.push({ table: "analytics_events", action: "delete", count: analyticsDel });
// ---- 10. Health/preferences/locale (delete) ----
await db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, userId));
await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, userId));
return log;
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
export * from "./gdpr-erasure.js";
export * from "./release-gates.js";
export * from "./activation.js";
export * from "./cooking.js";
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, timestamp, uuid } from "drizzle-orm/pg-core";
import { createdAt } from "./_shared.js";
import { users } from "./users.js";
/**
* GDPR-retention pseudonyms (spec §56, docs/29).
*
* Financial/audit records must be retained for legal/tax/dispute purposes
* (typically 7 years), but the natural person must not be directly
* identifiable in those tables after account deletion.
*
* This table holds a one-way-looking mapping from the original user id to
* a random pseudonym. The mapping itself is subject to strict access
* controls and is deleted when the retention period expires.
*
* NOTE: The exact legal mechanism (key escrow, retention policy, deletion
* job) must be confirmed in docs/23 before go-live.
*/
export const gdprRetentionPseudonyms = pgTable("gdpr_retention_pseudonyms", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" })
.unique(),
pseudonym: uuid("pseudonym").notNull().unique(),
/** When the mapping (and the retained records) may be hard-deleted. */
retentionUntil: timestamp("retention_until", { withTimezone: true }).notNull(),
createdAt: createdAt(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
});
+1
View File
@@ -1,5 +1,6 @@
export * from "./_shared.js";
export * from "./users.js";
export * from "./gdpr.js";
export * from "./locale.js";
export * from "./households.js";
export * from "./ingredients.js";
+3 -3
View File
@@ -204,9 +204,9 @@ export const cookingSessions = pgTable(
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
startedByUserId: uuid("started_by_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
startedByUserId: uuid("started_by_user_id").references(() => users.id, {
onDelete: "set null",
}),
status: text("status").notNull().default("planned"),
plannedPortions: integer("planned_portions").notNull(),
plannedMealType: mealTypeEnum("planned_meal_type").notNull().default("dinner"),
+23 -5
View File
@@ -20,15 +20,18 @@ import {
} from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
import { gdprRetentionPseudonyms } from "./gdpr.js";
/** Backend är source of truth för Premium (spec §47, §61.14). */
export const subscriptions = pgTable(
"subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while financial records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
provider: subscriptionProviderEnum("provider").notNull(),
productId: text("product_id").notNull(),
@@ -45,6 +48,7 @@ export const subscriptions = pgTable(
},
(t) => [
index("subscriptions_user_idx").on(t.userId),
index("subscriptions_pseudonym_idx").on(t.pseudonym),
uniqueIndex("subscriptions_original_tx_unique")
.on(t.provider, t.originalTransactionId)
.where(sql`${t.originalTransactionId} IS NOT NULL`),
@@ -60,11 +64,18 @@ export const subscriptionEvents = pgTable(
onDelete: "cascade",
}),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
eventType: text("event_type").notNull(),
payload: jsonb("payload"),
createdAt: createdAt(),
},
(t) => [index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt)],
(t) => [
index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt),
index("subscription_events_pseudonym_idx").on(t.pseudonym),
],
);
/** Råa store-transaktioner för revision (spec §47). */
@@ -76,12 +87,19 @@ export const storeTransactions = pgTable(
transactionId: text("transaction_id").notNull(),
originalTransactionId: text("original_transaction_id"),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
productId: text("product_id"),
rawPayload: jsonb("raw_payload").notNull(),
processedAt: timestamp("processed_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [uniqueIndex("store_transactions_unique").on(t.provider, t.transactionId)],
(t) => [
uniqueIndex("store_transactions_unique").on(t.provider, t.transactionId),
index("store_transactions_pseudonym_idx").on(t.pseudonym),
],
);
/** App Store Server Notifications / Play RTDN tas emot rått, processas av worker. */