fix: dedpulicating logic around post management

This commit is contained in:
2026-02-15 22:04:08 +01:00
parent 00351572ff
commit 8b938f4241
2 changed files with 125 additions and 90 deletions

View File

@@ -35,6 +35,82 @@ export class PostMediaEngine extends EventEmitter {
super(); super();
} }
private getDb() {
return getDatabase().getLocal();
}
private getUniqueMediaIds(mediaIds: string[]): string[] {
return Array.from(new Set(mediaIds));
}
private async addPostToMediaSidecar(mediaId: string, postId: string): Promise<void> {
const media = await getMediaEngine().getMedia(mediaId);
if (!media) {
return;
}
const linkedPostIds = media.linkedPostIds || [];
if (linkedPostIds.includes(postId)) {
return;
}
await getMediaEngine().updateMedia(mediaId, {
linkedPostIds: [...linkedPostIds, postId],
});
}
private async removePostFromMediaSidecar(mediaId: string, postId: string): Promise<void> {
const media = await getMediaEngine().getMedia(mediaId);
if (!media) {
return;
}
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
await getMediaEngine().updateMedia(mediaId, { linkedPostIds });
}
private createLinkData(link: NewPostMediaLink): PostMediaLinkData {
return {
id: link.id,
projectId: link.projectId,
postId: link.postId,
mediaId: link.mediaId,
sortOrder: link.sortOrder ?? 0,
createdAt: link.createdAt,
};
}
private async createPostMediaLink(postId: string, mediaId: string, sortOrder: number, createdAt: Date): Promise<PostMediaLinkData> {
const db = this.getDb();
const link: NewPostMediaLink = {
id: uuidv4(),
projectId: this.currentProjectId,
postId,
mediaId,
sortOrder,
createdAt,
};
await db.insert(postMedia).values(link);
await this.addPostToMediaSidecar(mediaId, postId);
return this.createLinkData(link);
}
private async removePostMediaLink(postId: string, mediaId: string): Promise<void> {
const db = this.getDb();
await db.delete(postMedia).where(
and(
eq(postMedia.postId, postId),
eq(postMedia.mediaId, mediaId)
)
);
await this.removePostFromMediaSidecar(mediaId, postId);
}
/** /**
* Set the current project context * Set the current project context
*/ */
@@ -47,45 +123,19 @@ export class PostMediaEngine extends EventEmitter {
* Link a media file to a post * Link a media file to a post
*/ */
async linkMediaToPost(postId: string, mediaId: string): Promise<PostMediaLinkData> { async linkMediaToPost(postId: string, mediaId: string): Promise<PostMediaLinkData> {
const db = getDatabase().getLocal(); const existingLinks = await this.getLinkedMediaForPost(postId);
const existing = existingLinks.find(link => link.mediaId === mediaId);
if (existing) {
return existing;
}
// Get current highest sortOrder for this post // Get current highest sortOrder for this post
const existingLinks = await this.getLinkedMediaForPost(postId);
const maxSortOrder = existingLinks.length > 0 const maxSortOrder = existingLinks.length > 0
? Math.max(...existingLinks.map(l => l.sortOrder)) ? Math.max(...existingLinks.map(l => l.sortOrder))
: -1; : -1;
const now = new Date(); const now = new Date();
const link: NewPostMediaLink = { const linkData = await this.createPostMediaLink(postId, mediaId, maxSortOrder + 1, now);
id: uuidv4(),
projectId: this.currentProjectId,
postId,
mediaId,
sortOrder: maxSortOrder + 1,
createdAt: now,
};
await db.insert(postMedia).values(link);
// Update the media sidecar to include this post
const media = await getMediaEngine().getMedia(mediaId);
if (media) {
const linkedPostIds = media.linkedPostIds || [];
if (!linkedPostIds.includes(postId)) {
await getMediaEngine().updateMedia(mediaId, {
linkedPostIds: [...linkedPostIds, postId],
});
}
}
const linkData: PostMediaLinkData = {
id: link.id,
projectId: link.projectId,
postId: link.postId,
mediaId: link.mediaId,
sortOrder: link.sortOrder ?? 0,
createdAt: now,
};
this.emit('mediaLinked', linkData); this.emit('mediaLinked', linkData);
return linkData; return linkData;
@@ -95,21 +145,7 @@ export class PostMediaEngine extends EventEmitter {
* Unlink a media file from a post * Unlink a media file from a post
*/ */
async unlinkMediaFromPost(postId: string, mediaId: string): Promise<void> { async unlinkMediaFromPost(postId: string, mediaId: string): Promise<void> {
const db = getDatabase().getLocal(); await this.removePostMediaLink(postId, mediaId);
await db.delete(postMedia).where(
and(
eq(postMedia.postId, postId),
eq(postMedia.mediaId, mediaId)
)
);
// Update the media sidecar to remove this post
const media = await getMediaEngine().getMedia(mediaId);
if (media) {
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
await getMediaEngine().updateMedia(mediaId, { linkedPostIds });
}
this.emit('mediaUnlinked', { postId, mediaId }); this.emit('mediaUnlinked', { postId, mediaId });
} }
@@ -120,9 +156,9 @@ export class PostMediaEngine extends EventEmitter {
* Skips media that are already linked. * Skips media that are already linked.
*/ */
async linkManyToPost(postId: string, mediaIds: string[]): Promise<{ linked: string[]; skipped: string[] }> { async linkManyToPost(postId: string, mediaIds: string[]): Promise<{ linked: string[]; skipped: string[] }> {
const db = getDatabase().getLocal();
const linked: string[] = []; const linked: string[] = [];
const skipped: string[] = []; const skipped: string[] = [];
const uniqueMediaIds = this.getUniqueMediaIds(mediaIds);
// Get all existing links for this post to check what's already linked // Get all existing links for this post to check what's already linked
const existingLinks = await this.getLinkedMediaForPost(postId); const existingLinks = await this.getLinkedMediaForPost(postId);
@@ -134,7 +170,7 @@ export class PostMediaEngine extends EventEmitter {
const now = new Date(); const now = new Date();
for (const mediaId of mediaIds) { for (const mediaId of uniqueMediaIds) {
// Skip if already linked // Skip if already linked
if (existingMediaIds.has(mediaId)) { if (existingMediaIds.has(mediaId)) {
skipped.push(mediaId); skipped.push(mediaId);
@@ -142,27 +178,7 @@ export class PostMediaEngine extends EventEmitter {
} }
maxSortOrder++; maxSortOrder++;
const link: NewPostMediaLink = { await this.createPostMediaLink(postId, mediaId, maxSortOrder, now);
id: uuidv4(),
projectId: this.currentProjectId,
postId,
mediaId,
sortOrder: maxSortOrder,
createdAt: now,
};
await db.insert(postMedia).values(link);
// Update the media sidecar to include this post
const media = await getMediaEngine().getMedia(mediaId);
if (media) {
const linkedPostIds = media.linkedPostIds || [];
if (!linkedPostIds.includes(postId)) {
await getMediaEngine().updateMedia(mediaId, {
linkedPostIds: [...linkedPostIds, postId],
});
}
}
linked.push(mediaId); linked.push(mediaId);
existingMediaIds.add(mediaId); // Track to avoid duplicates within batch existingMediaIds.add(mediaId); // Track to avoid duplicates within batch
@@ -181,23 +197,11 @@ export class PostMediaEngine extends EventEmitter {
* Only emits a single event at the end instead of per-item events. * Only emits a single event at the end instead of per-item events.
*/ */
async unlinkManyFromPost(postId: string, mediaIds: string[]): Promise<{ unlinked: string[] }> { async unlinkManyFromPost(postId: string, mediaIds: string[]): Promise<{ unlinked: string[] }> {
const db = getDatabase().getLocal();
const unlinked: string[] = []; const unlinked: string[] = [];
const uniqueMediaIds = this.getUniqueMediaIds(mediaIds);
for (const mediaId of mediaIds) { for (const mediaId of uniqueMediaIds) {
await db.delete(postMedia).where( await this.removePostMediaLink(postId, mediaId);
and(
eq(postMedia.postId, postId),
eq(postMedia.mediaId, mediaId)
)
);
// Update the media sidecar to remove this post
const media = await getMediaEngine().getMedia(mediaId);
if (media) {
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
await getMediaEngine().updateMedia(mediaId, { linkedPostIds });
}
unlinked.push(mediaId); unlinked.push(mediaId);
} }
@@ -214,7 +218,7 @@ export class PostMediaEngine extends EventEmitter {
* Get all media linked to a post, ordered by sortOrder * Get all media linked to a post, ordered by sortOrder
*/ */
async getLinkedMediaForPost(postId: string): Promise<PostMediaLinkData[]> { async getLinkedMediaForPost(postId: string): Promise<PostMediaLinkData[]> {
const db = getDatabase().getLocal(); const db = this.getDb();
const links = await db const links = await db
.select() .select()
@@ -234,7 +238,7 @@ export class PostMediaEngine extends EventEmitter {
* Get all posts linked to a media file * Get all posts linked to a media file
*/ */
async getLinkedPostsForMedia(mediaId: string): Promise<PostMediaLinkData[]> { async getLinkedPostsForMedia(mediaId: string): Promise<PostMediaLinkData[]> {
const db = getDatabase().getLocal(); const db = this.getDb();
const links = await db const links = await db
.select() .select()
@@ -255,7 +259,7 @@ export class PostMediaEngine extends EventEmitter {
* @param mediaIds Array of media IDs in the new desired order * @param mediaIds Array of media IDs in the new desired order
*/ */
async reorderMediaForPost(postId: string, mediaIds: string[]): Promise<void> { async reorderMediaForPost(postId: string, mediaIds: string[]): Promise<void> {
const db = getDatabase().getLocal(); const db = this.getDb();
// Update each media's sortOrder based on its position in the array // Update each media's sortOrder based on its position in the array
for (let i = 0; i < mediaIds.length; i++) { for (let i = 0; i < mediaIds.length; i++) {
@@ -278,7 +282,7 @@ export class PostMediaEngine extends EventEmitter {
* reconstructed from filesystem source of truth. * reconstructed from filesystem source of truth.
*/ */
async rebuildFromSidecars(): Promise<void> { async rebuildFromSidecars(): Promise<void> {
const db = getDatabase().getLocal(); const db = this.getDb();
console.log('[PostMediaEngine] Rebuilding post-media links from sidecars...'); console.log('[PostMediaEngine] Rebuilding post-media links from sidecars...');
@@ -346,7 +350,7 @@ export class PostMediaEngine extends EventEmitter {
* Check if a media is linked to a post * Check if a media is linked to a post
*/ */
async isMediaLinkedToPost(postId: string, mediaId: string): Promise<boolean> { async isMediaLinkedToPost(postId: string, mediaId: string): Promise<boolean> {
const db = getDatabase().getLocal(); const db = this.getDb();
const link = await db const link = await db
.select() .select()

View File

@@ -205,6 +205,23 @@ describe('PostMediaEngine', () => {
expect.objectContaining({ postId, mediaId }) expect.objectContaining({ postId, mediaId })
); );
}); });
it('should not create a duplicate link when media is already linked to the post', async () => {
const postId = 'post-1';
const mediaId = 'media-1';
selectMockData = [
{ id: 'existing-link', projectId: 'test-project', postId, mediaId, sortOrder: 2, createdAt: new Date() },
];
mockGetMedia.mockResolvedValue(createMockMedia({ id: mediaId, linkedPostIds: [postId] }));
const result = await engine.linkMediaToPost(postId, mediaId);
expect(insertedValues).toHaveLength(0);
expect(result.id).toBe('existing-link');
expect(result.sortOrder).toBe(2);
});
}); });
describe('unlinkMediaFromPost', () => { describe('unlinkMediaFromPost', () => {
@@ -444,6 +461,20 @@ describe('PostMediaEngine', () => {
expect.objectContaining({ linkedPostIds: ['other-post'] }) expect.objectContaining({ linkedPostIds: ['other-post'] })
); );
}); });
it('should process duplicate media IDs only once in a single batch', async () => {
const postId = 'post-1';
const mediaIds = ['media-1', 'media-1', 'media-2'];
mockGetMedia.mockImplementation((id: string) =>
Promise.resolve(createMockMedia({ id, linkedPostIds: [postId, 'other-post'] }))
);
const result = await engine.unlinkManyFromPost(postId, mediaIds);
expect(result.unlinked).toEqual(['media-1', 'media-2']);
expect(mockUpdateMedia).toHaveBeenCalledTimes(2);
});
}); });
describe('getLinkedPostsForMedia', () => { describe('getLinkedPostsForMedia', () => {