refactor(visibility): M6.1 — drop legacy isPublic everywhere

Removes the deprecated `isPublic` field from picture, memoro, cards,
presi, and uload. The unified `visibility` enum has been the source
of truth since M3 (picture) / M6 (others) and the soft-fallback in
queries was the last consumer of the legacy field. Killing it
cleanly:

- types: drops isPublic from LocalX + X interfaces, and from
  CreateDeckInput/UpdateDeckInput/UpdateDeckDto.
- queries.ts: type converters now read `visibility ?? 'space'` (or
  'private' for picture) without the isPublic fallback. Cards's
  `getPublicDecks` helper now filters on `visibility === 'public'`.
- stores: createX no longer initializes isPublic; updateX no longer
  accepts/mirrors it; setVisibility no longer writes the mirror.
- UI: cards CreateDeckModal drops the public-toggle (use the
  Picker in DetailView post-create); DeckCard + presi ListView +
  /cards/decks/[id] page badges read `visibility === 'public'`.
- collections.ts: drops isPublic: false from seed rows.
- embeds.ts: picture/memoro/cards/presi resolvers drop the
  isPublic fallback. Top comment updated to reference
  canEmbedOnWebsite as the canonical gate.

Existing IndexedDB rows still carry the stale isPublic value but
nothing reads it. No Dexie schema bump needed (field was never
indexed). No data loss — visibility was mirrored on every flip
during the soft-migrate window so all "public" intent has already
propagated to the unified field.

Closes M6.1 — picture/memoro/cards/presi/uload now have no
legacy visibility flags. Events' isPublished/publicToken stays
(orthogonal RSVP-snapshot system, not legacy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 18:16:57 +02:00
parent 0ff5030ad2
commit 303058d406
26 changed files with 32 additions and 142 deletions

View file

@ -24,7 +24,6 @@ export const CARDS_GUEST_SEED = {
description: 'Lerne Cards kennen mit diesen Beispiel-Karteikarten.',
color: '#6366f1',
cardCount: 3,
isPublic: false,
},
],
cards: [

View file

@ -13,7 +13,6 @@
let title = $state('');
let description = $state('');
let isPublic = $state(false);
let color = $state(DEFAULT_COLOR);
let submitting = $state(false);
let selectedTagIds = $state<string[]>([]);
@ -27,7 +26,6 @@
const deck = await deckStore.createDeck({
title: title.trim(),
description: description.trim() || undefined,
isPublic,
});
submitting = false;
@ -35,7 +33,6 @@
if (deck) {
title = '';
description = '';
isPublic = false;
open = false;
onClose?.();
}
@ -96,18 +93,6 @@
></textarea>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="deck-public"
bind:checked={isPublic}
class="h-4 w-4 rounded border-border"
/>
<label for="deck-public" class="cursor-pointer text-sm text-foreground">
Offentlich machen
</label>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Tags</span>
<TagField

View file

@ -39,7 +39,7 @@
>
<div class="flex items-center gap-2">
<span>{deck.cardCount || 0} Karten</span>
{#if deck.isPublic}
{#if deck.visibility === 'public'}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Öffentlich
</span>

View file

@ -18,8 +18,7 @@ export function toDeck(local: LocalDeck): Deck {
title: local.name,
description: local.description ?? undefined,
color: local.color,
isPublic: local.isPublic ?? local.visibility === 'public',
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
visibility: local.visibility ?? 'space',
tags: [],
cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(),
@ -83,7 +82,7 @@ export function getDeckById(decks: Deck[], id: string): Deck | undefined {
}
export function getPublicDecks(decks: Deck[]): Deck[] {
return decks.filter((d) => d.isPublic);
return decks.filter((d) => d.visibility === 'public');
}
export function getCardCountForDeck(cards: Card[], deckId: string): number {

View file

@ -28,17 +28,13 @@ export const deckStore = {
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
error = null;
try {
const initialPublic = input.isPublic ?? false;
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
name: input.title,
description: input.description ?? null,
color: '#6366f1',
cardCount: 0,
isPublic: initialPublic,
// Initialize the unified field too — if the create flow set
// isPublic, mirror it as 'public'; otherwise space-default.
visibility: initialPublic ? 'public' : defaultVisibilityFor(getActiveSpace()?.type),
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const plaintextSnapshot = toDeck(newLocal);
@ -59,12 +55,6 @@ export const deckStore = {
const localUpdates: Partial<LocalDeck> = {};
if (updates.title !== undefined) localUpdates.name = updates.title;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.isPublic !== undefined) {
// Legacy callers still pass isPublic — mirror to visibility
// so the unified field stays in sync until M6.1 hard-drop.
localUpdates.isPublic = updates.isPublic;
localUpdates.visibility = updates.isPublic ? 'public' : 'space';
}
const diff: Partial<LocalDeck> = {
...localUpdates,
@ -79,20 +69,18 @@ export const deckStore = {
},
/**
* Flip a deck's visibility. M6 soft-migration: writes both
* `visibility` and the legacy `isPublic` mirror so the picker
* coexists with the older "public" badge UI until M6.1 hard-drop.
* Flip a deck's visibility. Public decks surface in the cards
* embed-resolver on the user's website.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await cardDeckTable.get(id);
if (!existing) throw new Error(`Deck ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? (existing.isPublic ? 'public' : 'space');
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const stamp = new Date().toISOString();
await cardDeckTable.update(id, {
visibility: next,
isPublic: next === 'public',
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,

View file

@ -11,8 +11,6 @@ export interface LocalDeck extends BaseRecord {
color: string;
cardCount: number;
lastStudied?: string | null;
/** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */
isPublic: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
@ -36,8 +34,6 @@ export interface Deck {
title: string;
description?: string;
color: string;
/** @deprecated Use `visibility`. */
isPublic: boolean;
visibility: VisibilityLevel;
tags: string[];
cardCount: number;
@ -61,13 +57,11 @@ export interface Card {
export interface CreateDeckInput {
title: string;
description?: string;
isPublic?: boolean;
}
export interface UpdateDeckInput {
title?: string;
description?: string;
isPublic?: boolean;
}
export interface CreateCardInput {

View file

@ -98,7 +98,7 @@
<div class="prop-row">
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={deck.visibility ?? (deck.isPublic ? 'public' : 'space')}
level={deck.visibility ?? 'space'}
onChange={(next: VisibilityLevel) => deckStore.setVisibility(deckId, next)}
disabledLevels={['unlisted']}
/>

View file

@ -39,7 +39,6 @@ export const MEMORO_GUEST_SEED = {
processingStatus: 'completed' as const,
isArchived: false,
isPinned: true,
isPublic: false,
blueprintId: null,
language: 'de',
},

View file

@ -35,12 +35,7 @@ export function toMemo(local: LocalMemo): Memo {
processingStatus: local.processingStatus,
isArchived: local.isArchived,
isPinned: local.isPinned,
isPublic: local.isPublic ?? local.visibility === 'public',
// Soft-fallback during M6 soak: legacy rows use isPublic, new
// writes set visibility directly. Keep both in sync via the
// store's setVisibility/setPublic methods until M6.1 drops the
// legacy field.
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
visibility: local.visibility ?? 'space',
language: local.language,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),

View file

@ -45,7 +45,6 @@ export const memosStore = {
processingStatus: data.processingStatus ?? (data.transcript ? 'completed' : 'pending'),
isArchived: false,
isPinned: false,
isPublic: false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
blueprintId: data.blueprintId ?? null,
language: data.language ?? null,
@ -141,7 +140,7 @@ export const memosStore = {
/** Update a memo's fields. */
async update(
id: string,
data: Partial<Pick<LocalMemo, 'title' | 'intro' | 'transcript' | 'language' | 'isPublic'>>
data: Partial<Pick<LocalMemo, 'title' | 'intro' | 'transcript' | 'language'>>
) {
const diff: Partial<LocalMemo> = {
...data,
@ -173,22 +172,18 @@ export const memosStore = {
unarchive: (id: string) => memoArchive.unarchive(id),
/**
* Flip a memo's visibility. M6 soft-migration: writes both
* `visibility` and the legacy `isPublic` mirror so older readers
* (search index, server snapshots) keep working until the M6.1
* hard-drop. Public memos surface in the user's website embed
* once a memoro embed-resolver lands.
* Flip a memo's visibility. Public memos surface in the user's
* website embed via the memoro embed-resolver.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await memoTable.get(id);
if (!existing) throw new Error(`Memo ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? (existing.isPublic ? 'public' : 'space');
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const stamp = new Date().toISOString();
await memoTable.update(id, {
visibility: next,
isPublic: next === 'public',
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,

View file

@ -16,14 +16,6 @@ export interface LocalMemo extends BaseRecord {
processingStatus: ProcessingStatus;
isArchived: boolean;
isPinned: boolean;
/**
* @deprecated Soft-migrating to unified `visibility`. Kept for the
* soak window so the converter can fall back to `isPublic` for
* legacy rows that haven't been touched since the M6 rollout.
* Hard-drop once `visibility` has propagated to all rows.
*/
isPublic?: boolean;
/** Unified visibility (M6 pilot — replaces isPublic). */
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
@ -95,8 +87,6 @@ export interface Memo {
processingStatus: ProcessingStatus;
isArchived: boolean;
isPinned: boolean;
/** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */
isPublic: boolean;
visibility: VisibilityLevel;
language: string | null;
createdAt: string;

View file

@ -194,7 +194,7 @@
<div class="prop-row">
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={memo.visibility ?? (memo.isPublic ? 'public' : 'space')}
level={memo.visibility ?? 'space'}
onChange={(next: VisibilityLevel) => memosStore.setVisibility(memoId, next)}
disabledLevels={['unlisted']}
/>

View file

@ -38,9 +38,7 @@ export function toImage(local: LocalImage): Image {
height: local.height ?? undefined,
fileSize: local.fileSize ?? undefined,
blurhash: local.blurhash ?? undefined,
// Soft-migration fallback: rows written before M3 only have the
// legacy `isPublic` flag; map it to the nearest visibility level.
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
visibility: local.visibility ?? 'private',
isFavorite: local.isFavorite,
downloadCount: local.downloadCount,
rating: local.rating ?? undefined,
@ -68,7 +66,7 @@ export function toBoard(local: LocalBoard): Board {
canvasWidth: local.canvasWidth,
canvasHeight: local.canvasHeight,
backgroundColor: local.backgroundColor,
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'private'),
visibility: local.visibility ?? 'private',
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -190,8 +190,7 @@ export const boardsStore = {
try {
const existing = await db.table<LocalBoard>('boards').get(id);
if (!existing) return { success: false, error: 'Board not found' };
const before: VisibilityLevel =
existing.visibility ?? (existing.isPublic === true ? 'public' : 'private');
const before: VisibilityLevel = existing.visibility ?? 'private';
if (before === next) return { success: true };
const now = new Date().toISOString();

View file

@ -26,12 +26,6 @@ export interface LocalImage extends BaseRecord {
height?: number | null;
fileSize?: number | null;
blurhash?: string | null;
/**
* @deprecated Use `visibility` instead. Kept for the soft-migration
* window will be dropped in the hard follow-up once no reader
* references it. See docs/plans/visibility-system.md §M3.
*/
isPublic?: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
@ -95,11 +89,6 @@ export interface LocalBoard extends BaseRecord {
canvasWidth: number;
canvasHeight: number;
backgroundColor: string;
/**
* @deprecated Use `visibility` instead. Kept during the M3 soft
* migration dropped in the hard follow-up.
*/
isPublic?: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;

View file

@ -110,7 +110,7 @@
<p class="truncate text-sm font-medium text-foreground">{deck.title}</p>
<div class="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{slideCount(deck.id)} Folien</span>
{#if deck.isPublic}
{#if deck.visibility === 'public'}
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px]">Öffentlich</span>
{/if}
</div>

View file

@ -22,7 +22,6 @@ export const PRESI_GUEST_SEED = {
id: ONBOARDING_DECK_ID,
title: 'Willkommen bei Presi',
description: 'Eine kurze Einfuhrung in die Prasentations-App.',
isPublic: false,
},
],
slides: [

View file

@ -19,8 +19,7 @@ export function toDeck(local: LocalDeck): Deck {
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic ?? local.visibility === 'public',
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -39,7 +39,6 @@ function createDecksStore() {
title: dto.title,
description: dto.description || null,
themeId: dto.themeId || null,
isPublic: false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const plaintextSnapshot = toDeck(newLocal);
@ -65,11 +64,6 @@ function createDecksStore() {
if (dto.title !== undefined) localUpdates.title = dto.title;
if (dto.description !== undefined) localUpdates.description = dto.description;
if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId;
if (dto.isPublic !== undefined) {
// Mirror to unified visibility during M6 soak.
localUpdates.isPublic = dto.isPublic;
localUpdates.visibility = dto.isPublic ? 'public' : 'space';
}
await encryptRecord('presiDecks', localUpdates);
await presiDeckTable.update(id, localUpdates);
@ -85,13 +79,11 @@ function createDecksStore() {
try {
const existing = await presiDeckTable.get(id);
if (!existing) return false;
const before: VisibilityLevel =
existing.visibility ?? (existing.isPublic ? 'public' : 'space');
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return true;
const stamp = new Date().toISOString();
await presiDeckTable.update(id, {
visibility: next,
isPublic: next === 'public',
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,

View file

@ -9,8 +9,6 @@ export interface LocalDeck extends BaseRecord {
title: string;
description?: string | null;
themeId?: string | null;
/** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */
isPublic: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
@ -39,8 +37,6 @@ export interface Deck {
title: string;
description?: string;
themeId?: string;
/** @deprecated Use `visibility`. */
isPublic: boolean;
visibility: VisibilityLevel;
createdAt: string;
updatedAt: string;
@ -66,7 +62,6 @@ export interface UpdateDeckDto {
title?: string;
description?: string;
themeId?: string;
isPublic?: boolean;
}
export interface CreateSlideDto {

View file

@ -84,7 +84,7 @@
<div class="prop-row">
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={deck.visibility ?? (deck.isPublic ? 'public' : 'space')}
level={deck.visibility ?? 'space'}
onChange={(next: VisibilityLevel) => decksStore.setVisibility(deckId, next)}
disabledLevels={['unlisted']}
/>

View file

@ -87,7 +87,6 @@ export const ULOAD_GUEST_SEED = {
slug: 'social-media',
color: '#8b5cf6',
icon: null,
isPublic: false,
visibility: 'space',
usageCount: 0,
},
@ -97,7 +96,6 @@ export const ULOAD_GUEST_SEED = {
slug: 'dokumentation',
color: '#3b82f6',
icon: null,
isPublic: false,
visibility: 'space',
usageCount: 0,
},
@ -107,7 +105,6 @@ export const ULOAD_GUEST_SEED = {
slug: 'marketing',
color: '#10b981',
icon: null,
isPublic: false,
visibility: 'space',
usageCount: 0,
},

View file

@ -42,8 +42,6 @@ export interface Tag {
slug: string;
color?: string;
icon?: string;
/** @deprecated Use `visibility`. */
isPublic: boolean;
visibility: import('@mana/shared-privacy').VisibilityLevel;
usageCount: number;
createdAt: string;
@ -106,8 +104,7 @@ export function toTag(local: LocalTag): Tag {
slug: local.slug,
color: local.color ?? undefined,
icon: local.icon ?? undefined,
isPublic: local.isPublic ?? local.visibility === 'public',
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
visibility: local.visibility ?? 'space',
usageCount: local.usageCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),

View file

@ -30,12 +30,6 @@ export interface LocalTag extends BaseRecord {
slug: string;
color?: string | null;
icon?: string | null;
/**
* @deprecated Use `visibility`. Mirror kept for the M6 soak window.
* No active CRUD UI yet the field is set only by seed data and
* the future tag-management view will write `visibility` directly.
*/
isPublic: boolean;
visibility?: VisibilityLevel;
usageCount: number;
}

View file

@ -9,10 +9,10 @@ import { formatDateTime } from '$lib/i18n/format';
* content. Trade-off: publishes are slightly slower, public visits are
* much faster.
*
* Every resolver MUST enforce the source's public-visibility rules
* e.g. `picture.board.isPublic === true`. An owner who embeds a
* private board gets an empty result with a clear error message in the
* resolved.error field.
* Every resolver MUST enforce the source's public-visibility rules
* via `canEmbedOnWebsite(visibility)`. An owner who embeds a
* non-public record gets an empty result with a clear error message
* in the resolved.error field.
*/
import { db } from '$lib/data/database';
@ -117,8 +117,7 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
/**
* Picture-board: returns image items for a board whose owner flipped
* its visibility to 'public' via the VisibilityPicker. `canEmbedOnWebsite`
* is the hard gate; the soft-migration fallback maps legacy `isPublic`
* rows (pre-M3) to the right level.
* is the hard gate.
*/
async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]> {
if (!props.sourceId) {
@ -134,8 +133,7 @@ async function resolvePictureBoard(props: ModuleEmbedProps): Promise<EmbedItem[]
if (!rawBoard || rawBoard.deletedAt) {
throw new Error('Board nicht gefunden');
}
const boardVisibility =
rawBoard.visibility ?? (rawBoard.isPublic === true ? 'public' : 'private');
const boardVisibility = rawBoard.visibility ?? 'private';
if (!canEmbedOnWebsite(boardVisibility)) {
throw new Error('Board ist nicht öffentlich — setze es im Picture-Modul auf "Öffentlich"');
}
@ -804,10 +802,7 @@ async function resolveSocialEvents(props: ModuleEmbedProps): Promise<EmbedItem[]
async function resolveMemos(_props: ModuleEmbedProps): Promise<EmbedItem[]> {
let memos = await db.table<LocalMemo>('memos').toArray();
memos = memos.filter(
(m) =>
!m.deletedAt &&
!m.isArchived &&
canEmbedOnWebsite(m.visibility ?? (m.isPublic === true ? 'public' : 'private'))
(m) => !m.deletedAt && !m.isArchived && canEmbedOnWebsite(m.visibility ?? 'private')
);
if (memos.length === 0) return [];
@ -853,11 +848,7 @@ async function resolveMemos(_props: ModuleEmbedProps): Promise<EmbedItem[]> {
*/
async function resolveCardDecks(_props: ModuleEmbedProps): Promise<EmbedItem[]> {
let decks = await db.table<LocalCardDeck>('cardDecks').toArray();
decks = decks.filter(
(d) =>
!d.deletedAt &&
canEmbedOnWebsite(d.visibility ?? (d.isPublic === true ? 'public' : 'private'))
);
decks = decks.filter((d) => !d.deletedAt && canEmbedOnWebsite(d.visibility ?? 'private'));
if (decks.length === 0) return [];
@ -886,11 +877,7 @@ async function resolveCardDecks(_props: ModuleEmbedProps): Promise<EmbedItem[]>
*/
async function resolvePresiDecks(_props: ModuleEmbedProps): Promise<EmbedItem[]> {
let decks = await db.table<LocalPresiDeck>('presiDecks').toArray();
decks = decks.filter(
(d) =>
!d.deletedAt &&
canEmbedOnWebsite(d.visibility ?? (d.isPublic === true ? 'public' : 'private'))
);
decks = decks.filter((d) => !d.deletedAt && canEmbedOnWebsite(d.visibility ?? 'private'));
if (decks.length === 0) return [];

View file

@ -91,7 +91,7 @@
</div>
<div class="flex items-center gap-2">
{#if deck.isPublic}
{#if deck.visibility === 'public'}
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs text-primary">
Offentlich
</span>