WIP - sharing list views

This commit is contained in:
Francisco Gaona
2026-04-10 10:57:20 +02:00
parent 12304d5890
commit fb2533fa4c
7 changed files with 382 additions and 128 deletions

View File

@@ -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<void> }
*/
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<void> }
*/
exports.down = async function (knex) {
await knex('object_definitions')
.where({ apiName: 'SavedListView' })
.delete();
};

View File

@@ -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<{

View File

@@ -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);
}
}

View File

@@ -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<string> {
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<string, any> = { 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,