From fb2533fa4c6671599f09602f0d6621ce77327a35 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Fri, 10 Apr 2026 10:57:20 +0200 Subject: [PATCH] WIP - sharing list views --- ...2_add_saved_list_view_object_definition.js | 35 +++ .../dto/saved-list-view.dto.ts | 6 +- .../saved-list-view.controller.ts | 34 +++ .../saved-list-view.service.ts | 171 +++++++++++- frontend/components/RecordSharing.vue | 15 +- frontend/components/SavedViewPanel.vue | 248 ++++++++++-------- frontend/composables/useSavedViews.ts | 1 - 7 files changed, 382 insertions(+), 128 deletions(-) create mode 100644 backend/migrations/tenant/20260410000002_add_saved_list_view_object_definition.js diff --git a/backend/migrations/tenant/20260410000002_add_saved_list_view_object_definition.js b/backend/migrations/tenant/20260410000002_add_saved_list_view_object_definition.js new file mode 100644 index 0000000..930e189 --- /dev/null +++ b/backend/migrations/tenant/20260410000002_add_saved_list_view_object_definition.js @@ -0,0 +1,35 @@ +/** + * Inserts a system object_definition row for SavedListView. + * This allows saved_list_views records to be shared via record_shares + * (which requires a valid objectDefinitionId FK). + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + // Only insert if it doesn't already exist (idempotent) + const existing = await knex('object_definitions') + .where({ apiName: 'SavedListView' }) + .first(); + + if (!existing) { + await knex('object_definitions').insert({ + apiName: 'SavedListView', + label: 'Saved List View', + pluralLabel: 'Saved List Views', + description: 'System object for sharing saved list views via record_shares', + isSystem: true, + isCustom: false, + }); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function (knex) { + await knex('object_definitions') + .where({ apiName: 'SavedListView' }) + .delete(); +}; diff --git a/backend/src/saved-list-view/dto/saved-list-view.dto.ts b/backend/src/saved-list-view/dto/saved-list-view.dto.ts index d96fdad..108a554 100644 --- a/backend/src/saved-list-view/dto/saved-list-view.dto.ts +++ b/backend/src/saved-list-view/dto/saved-list-view.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsArray, IsOptional, IsBoolean } from 'class-validator'; +import { IsString, IsNotEmpty, IsArray, IsOptional } from 'class-validator'; export class CreateSavedViewDto { @IsString() @@ -33,10 +33,6 @@ export class UpdateSavedViewDto { @IsNotEmpty() name?: string; - @IsOptional() - @IsBoolean() - isShared?: boolean; - @IsOptional() @IsArray() filters?: Array<{ diff --git a/backend/src/saved-list-view/saved-list-view.controller.ts b/backend/src/saved-list-view/saved-list-view.controller.ts index eed4bc3..2a00408 100644 --- a/backend/src/saved-list-view/saved-list-view.controller.ts +++ b/backend/src/saved-list-view/saved-list-view.controller.ts @@ -7,12 +7,15 @@ import { Body, Param, UseGuards, + ForbiddenException, + NotFoundException, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { TenantId } from '../tenant/tenant.decorator'; import { SavedListViewService } from './saved-list-view.service'; import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto'; +import { CreateRecordShareDto } from '../rbac/dto/create-record-share.dto'; @Controller('saved-views') @UseGuards(JwtAuthGuard) @@ -55,4 +58,35 @@ export class SavedListViewController { ) { return this.savedListViewService.remove(tenantId, user.userId, id); } + + // ── Sharing endpoints (reuse record_shares table) ──────────────────────── + + @Get(':id/shares') + getShares( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('id') id: string, + ) { + return this.savedListViewService.getShares(tenantId, user.userId, id); + } + + @Post(':id/shares') + createShare( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('id') id: string, + @Body() dto: CreateRecordShareDto, + ) { + return this.savedListViewService.createShare(tenantId, user.userId, id, dto); + } + + @Delete(':id/shares/:shareId') + removeShare( + @TenantId() tenantId: string, + @CurrentUser() user: any, + @Param('id') id: string, + @Param('shareId') shareId: string, + ) { + return this.savedListViewService.removeShare(tenantId, user.userId, id, shareId); + } } diff --git a/backend/src/saved-list-view/saved-list-view.service.ts b/backend/src/saved-list-view/saved-list-view.service.ts index 141ac10..84a3149 100644 --- a/backend/src/saved-list-view/saved-list-view.service.ts +++ b/backend/src/saved-list-view/saved-list-view.service.ts @@ -1,24 +1,61 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto'; +import { RecordShare } from '../models/record-share.model'; +import { ObjectDefinition } from '../models/object-definition.model'; @Injectable() export class SavedListViewService { constructor(private readonly tenantDbService: TenantDatabaseService) {} + // ── Helpers ────────────────────────────────────────────────────────────── + + /** + * Resolves the system object_definition ID for SavedListView. + * This is needed to create record_shares rows for saved views. + */ + private async getSavedViewObjectDefId(knex: any): Promise { + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: 'SavedListView' }); + if (!objectDef) { + throw new BadRequestException( + 'SavedListView system object not found. Please run migrations.', + ); + } + return objectDef.id; + } + + // ── CRUD ───────────────────────────────────────────────────────────────── + /** * Returns all saved views visible to the user for a given object: - * - Views owned by the user (private or shared) - * - Views owned by other users that are shared with the tenant + * - Views owned by the user + * - Views shared with the user via record_shares */ async findByObject(tenantId: string, userId: string, objectApiName: string) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + const objectDefId = await this.getSavedViewObjectDefId(knex); + + // IDs of views shared with this user via record_shares + const sharedViewIds = await RecordShare.query(knex) + .where({ objectDefinitionId: objectDefId, granteeUserId: userId }) + .whereNull('revokedAt') + .where(builder => { + builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date()); + }) + .select('recordId'); + + const sharedIds = sharedViewIds.map((s: any) => s.recordId); + const rows = await knex('saved_list_views') .where({ object_api_name: objectApiName }) .andWhere(function () { - this.where({ user_id: userId }).orWhere({ is_shared: true }); + this.where({ user_id: userId }); + if (sharedIds.length > 0) { + this.orWhereIn('id', sharedIds); + } }) .orderBy('created_at', 'asc'); @@ -29,8 +66,6 @@ export class SavedListViewService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // MySQL Knex returns [0] for UUID PKs (not auto-increment), so generate the - // UUID in application code and use it directly for the subsequent fetch. const id = require('crypto').randomUUID(); await knex('saved_list_views').insert({ @@ -61,7 +96,6 @@ export class SavedListViewService { const updates: Record = { updated_at: knex.fn.now() }; if (dto.name !== undefined) updates.name = dto.name; - if (dto.isShared !== undefined) updates.is_shared = dto.isShared; if (dto.filters !== undefined) updates.filters = JSON.stringify(dto.filters); if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null; if (dto.description !== undefined) updates.description = dto.description; @@ -82,10 +116,133 @@ export class SavedListViewService { throw new ForbiddenException('You can only delete views you own'); } + // Also clean up any record_shares for this view + const objectDefId = await this.getSavedViewObjectDefId(knex); + await RecordShare.query(knex) + .where({ objectDefinitionId: objectDefId, recordId: id }) + .delete(); + await knex('saved_list_views').where({ id }).delete(); return { deleted: true }; } + // ── Sharing via record_shares ──────────────────────────────────────────── + + async getShares(tenantId: string, userId: string, viewId: string) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const view = await knex('saved_list_views').where({ id: viewId }).first(); + if (!view) throw new NotFoundException('Saved view not found'); + if (view.user_id !== userId) { + throw new ForbiddenException('Only the view owner can manage sharing'); + } + + const objectDefId = await this.getSavedViewObjectDefId(knex); + + const shares = await RecordShare.query(knex) + .where({ objectDefinitionId: objectDefId, recordId: viewId }) + .whereNull('revokedAt') + .where(builder => { + builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date()); + }) + .withGraphFetched('[granteeUser]') + .orderBy('createdAt', 'desc'); + + return shares; + } + + async createShare( + tenantId: string, + userId: string, + viewId: string, + dto: { granteeUserId: string; canRead: boolean; canEdit: boolean; canDelete: boolean; expiresAt?: string }, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const view = await knex('saved_list_views').where({ id: viewId }).first(); + if (!view) throw new NotFoundException('Saved view not found'); + if (view.user_id !== userId) { + throw new ForbiddenException('Only the view owner can share it'); + } + if (dto.granteeUserId === userId) { + throw new BadRequestException('Cannot share a view with yourself'); + } + + const objectDefId = await this.getSavedViewObjectDefId(knex); + + // Upsert: if non-revoked share already exists for this grantee, update it + const existing = await RecordShare.query(knex) + .where({ + objectDefinitionId: objectDefId, + recordId: viewId, + granteeUserId: dto.granteeUserId, + }) + .whereNull('revokedAt') + .first(); + + if (existing) { + await RecordShare.query(knex) + .patchAndFetchById(existing.id, { + accessLevel: { + canRead: dto.canRead, + canEdit: dto.canEdit, + canDelete: dto.canDelete, + }, + expiresAt: dto.expiresAt + ? (knex.raw('?', [new Date(dto.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) as any) + : null, + } as any); + + return RecordShare.query(knex) + .findById(existing.id) + .withGraphFetched('[granteeUser]'); + } + + const share = await RecordShare.query(knex).insertAndFetch({ + objectDefinitionId: objectDefId, + recordId: viewId, + granteeUserId: dto.granteeUserId, + grantedByUserId: userId, + accessLevel: { + canRead: dto.canRead, + canEdit: dto.canEdit, + canDelete: dto.canDelete, + }, + expiresAt: dto.expiresAt + ? (knex.raw('?', [new Date(dto.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) as any) + : null, + } as any); + + return RecordShare.query(knex) + .findById(share.id) + .withGraphFetched('[granteeUser]'); + } + + async removeShare(tenantId: string, userId: string, viewId: string, shareId: string) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const view = await knex('saved_list_views').where({ id: viewId }).first(); + if (!view) throw new NotFoundException('Saved view not found'); + if (view.user_id !== userId) { + throw new ForbiddenException('Only the view owner can manage sharing'); + } + + const share = await RecordShare.query(knex).findById(shareId); + if (!share) throw new NotFoundException('Share not found'); + + // Soft-revoke + await RecordShare.query(knex) + .findById(shareId) + .patch({ revokedAt: knex.fn.now() } as any); + + return { revoked: true }; + } + + // ── Serialisation ──────────────────────────────────────────────────────── + private deserialize(row: any, currentUserId: string) { return { id: row.id, diff --git a/frontend/components/RecordSharing.vue b/frontend/components/RecordSharing.vue index a2b7cc8..36506a9 100644 --- a/frontend/components/RecordSharing.vue +++ b/frontend/components/RecordSharing.vue @@ -186,6 +186,8 @@ interface Props { objectApiName: string; recordId: string; ownerId?: string; + /** Optional base URL override for shares API. Defaults to /runtime/objects/{objectApiName}/records/{recordId}/shares */ + basePath?: string; } const props = defineProps(); @@ -193,6 +195,11 @@ const props = defineProps(); const { api } = useApi(); const { toast } = useToast(); +/** Computed base path for all share API calls */ +const sharesBasePath = computed(() => + props.basePath || `/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares` +); + const loading = ref(true); const sharing = ref(false); const removing = ref(null); @@ -236,9 +243,7 @@ const loadShares = async () => { try { loading.value = true; error.value = null; - const response = await api.get( - `/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares` - ); + const response = await api.get(sharesBasePath.value); shares.value = response || []; } catch (e: any) { console.error('Failed to load shares:', e); @@ -286,7 +291,7 @@ const createShare = async () => { console.log('Final payload:', payload); await api.post( - `/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`, + sharesBasePath.value, payload ); toast.success('Record shared successfully'); @@ -313,7 +318,7 @@ const removeShare = async (shareId: string) => { try { removing.value = shareId; await api.delete( - `/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}` + `${sharesBasePath.value}/${shareId}` ); toast.success('Share removed successfully'); await loadShares(); diff --git a/frontend/components/SavedViewPanel.vue b/frontend/components/SavedViewPanel.vue index b045e49..b321ab7 100644 --- a/frontend/components/SavedViewPanel.vue +++ b/frontend/components/SavedViewPanel.vue @@ -1,5 +1,5 @@