167 lines
4.7 KiB
TypeScript
167 lines
4.7 KiB
TypeScript
import { ID, Query, databaseId, tables } from './appwrite';
|
||
import { teamRechte } from './permissions';
|
||
|
||
/**
|
||
* Ein Post ist ein **Slot-Rezept**, kein Freitext-Prompt – nur deshalb ist er
|
||
* überhaupt kopierbar (konzept-bilder-feed.md §3). Die Slots liegen strukturiert
|
||
* in `slots`, damit P19 daraus später Chips ableiten kann, ohne den Prompt im
|
||
* Wortlaut preiszugeben.
|
||
*/
|
||
export type Slots = {
|
||
kulisse_asset_id?: string;
|
||
person_asset_id?: string;
|
||
produkt_asset_id?: string;
|
||
licht?: string;
|
||
kamera?: string;
|
||
farbe?: string;
|
||
werbetext?: string;
|
||
};
|
||
|
||
export type Post = {
|
||
$id: string;
|
||
brand_id: string;
|
||
folder_id?: string;
|
||
titel?: string;
|
||
user_prompt?: string;
|
||
slots?: string;
|
||
format?: string;
|
||
bild_count?: number;
|
||
status?: 'entwurf' | 'generiert' | 'fehler';
|
||
sichtbarkeit?: 'privat' | 'oeffentlich';
|
||
nische?: string;
|
||
};
|
||
|
||
export const FORMATE = [
|
||
{ wert: '1:1', titel: 'Quadrat', erklaerung: 'Feed-Beiträge, Produktkacheln.' },
|
||
{ wert: '4:5', titel: 'Hochformat', erklaerung: 'Nimmt im Feed mehr Fläche ein.' },
|
||
{ wert: '9:16', titel: 'Story', erklaerung: 'Bildschirmfüllend, für Stories und Reels.' },
|
||
] as const;
|
||
|
||
export type Format = (typeof FORMATE)[number]['wert'];
|
||
|
||
export async function postsImOrdner(folderId: string): Promise<Post[]> {
|
||
const res = await tables.listRows({
|
||
databaseId,
|
||
tableId: 'posts',
|
||
queries: [Query.equal('folder_id', folderId), Query.orderDesc('$createdAt'), Query.limit(100)],
|
||
});
|
||
return res.rows as unknown as Post[];
|
||
}
|
||
|
||
export async function postsDerMarke(brandId: string): Promise<Post[]> {
|
||
const res = await tables.listRows({
|
||
databaseId,
|
||
tableId: 'posts',
|
||
queries: [Query.equal('brand_id', brandId), Query.orderDesc('$createdAt'), Query.limit(100)],
|
||
});
|
||
return res.rows as unknown as Post[];
|
||
}
|
||
|
||
/**
|
||
* Legt das Rezept an und reiht die Generierungen ein.
|
||
*
|
||
* Der Post entsteht **sofort** mit `status: 'entwurf'`, bevor irgendetwas
|
||
* generiert wird. Das ist Absicht: Generierung ist asynchron, und wer während
|
||
* des Wartens weg navigiert, muss das Ergebnis wiederfinden (app-aufbau.md
|
||
* §2.1). Ohne die Zeile gäbe es nichts, wohin man zurückkehren könnte.
|
||
*
|
||
* Je Bild der Kette eine `jobs`-Zeile. Abgearbeitet werden sie vom
|
||
* Job-Dispatcher mit Server-Key – **nicht** vom Client, denn dafür müsste der
|
||
* Anbieter-Schlüssel ins Bundle.
|
||
*/
|
||
export async function postAnlegen(
|
||
brandId: string,
|
||
teamId: string,
|
||
daten: {
|
||
folderId: string;
|
||
titel: string;
|
||
prompt: string;
|
||
format: Format;
|
||
bildCount: number;
|
||
slots: Slots;
|
||
nische?: string;
|
||
},
|
||
): Promise<Post> {
|
||
const rechte = teamRechte(teamId);
|
||
|
||
const post = (await tables.createRow({
|
||
databaseId,
|
||
tableId: 'posts',
|
||
rowId: ID.unique(),
|
||
data: {
|
||
brand_id: brandId,
|
||
folder_id: daten.folderId,
|
||
titel: daten.titel,
|
||
user_prompt: daten.prompt,
|
||
slots: JSON.stringify(daten.slots),
|
||
format: daten.format,
|
||
bild_count: daten.bildCount,
|
||
status: 'entwurf',
|
||
sichtbarkeit: 'privat', // Veröffentlichen ist ein aktiver, eigener Schritt
|
||
kopien_count: 0,
|
||
feed_score: 0,
|
||
...(daten.nische ? { nische: daten.nische } : {}),
|
||
},
|
||
permissions: rechte,
|
||
})) as unknown as Post;
|
||
|
||
for (let i = 0; i < daten.bildCount; i++) {
|
||
await tables.createRow({
|
||
databaseId,
|
||
tableId: 'jobs',
|
||
rowId: ID.unique(),
|
||
data: {
|
||
brand_id: brandId,
|
||
typ: 'bild_gen',
|
||
prompt_template_key: 'P7',
|
||
status: 'wartend',
|
||
refs: JSON.stringify({ post_id: post.$id, position: i + 1 }),
|
||
},
|
||
permissions: rechte,
|
||
});
|
||
}
|
||
|
||
return post;
|
||
}
|
||
|
||
export type PostBild = {
|
||
$id: string;
|
||
post_id: string;
|
||
position: number;
|
||
typ: 'motiv' | 'text_overlay';
|
||
storage_file_id?: string;
|
||
};
|
||
|
||
/**
|
||
* Die Bilderketten mehrerer Posts in einer Abfrage – nicht eine je Post.
|
||
* Ein Ordner mit 20 Posts wären sonst 20 Rundreisen für eine Listenansicht.
|
||
*/
|
||
export async function bilderZuPosts(postIds: string[]): Promise<Map<string, PostBild[]>> {
|
||
const nach = new Map<string, PostBild[]>();
|
||
if (!postIds.length) return nach;
|
||
|
||
const res = await tables.listRows({
|
||
databaseId,
|
||
tableId: 'post_images',
|
||
queries: [Query.equal('post_id', postIds), Query.orderAsc('position'), Query.limit(200)],
|
||
});
|
||
for (const b of res.rows as unknown as PostBild[]) {
|
||
const liste = nach.get(b.post_id) ?? [];
|
||
liste.push(b);
|
||
nach.set(b.post_id, liste);
|
||
}
|
||
return nach;
|
||
}
|
||
|
||
export async function postLoeschen(id: string): Promise<void> {
|
||
await tables.deleteRow({ databaseId, tableId: 'posts', rowId: id });
|
||
}
|
||
|
||
export function slotsLesen(post: Post): Slots {
|
||
try {
|
||
return post.slots ? (JSON.parse(post.slots) as Slots) : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|