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 { export class CreateSavedViewDto {
@IsString() @IsString()
@@ -33,10 +33,6 @@ export class UpdateSavedViewDto {
@IsNotEmpty() @IsNotEmpty()
name?: string; name?: string;
@IsOptional()
@IsBoolean()
isShared?: boolean;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
filters?: Array<{ filters?: Array<{

View File

@@ -7,12 +7,15 @@ import {
Body, Body,
Param, Param,
UseGuards, UseGuards,
ForbiddenException,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator'; import { TenantId } from '../tenant/tenant.decorator';
import { SavedListViewService } from './saved-list-view.service'; import { SavedListViewService } from './saved-list-view.service';
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto'; import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
import { CreateRecordShareDto } from '../rbac/dto/create-record-share.dto';
@Controller('saved-views') @Controller('saved-views')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -55,4 +58,35 @@ export class SavedListViewController {
) { ) {
return this.savedListViewService.remove(tenantId, user.userId, id); 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 { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto'; import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
import { RecordShare } from '../models/record-share.model';
import { ObjectDefinition } from '../models/object-definition.model';
@Injectable() @Injectable()
export class SavedListViewService { export class SavedListViewService {
constructor(private readonly tenantDbService: TenantDatabaseService) {} 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: * Returns all saved views visible to the user for a given object:
* - Views owned by the user (private or shared) * - Views owned by the user
* - Views owned by other users that are shared with the tenant * - Views shared with the user via record_shares
*/ */
async findByObject(tenantId: string, userId: string, objectApiName: string) { async findByObject(tenantId: string, userId: string, objectApiName: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); 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') const rows = await knex('saved_list_views')
.where({ object_api_name: objectApiName }) .where({ object_api_name: objectApiName })
.andWhere(function () { .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'); .orderBy('created_at', 'asc');
@@ -29,8 +66,6 @@ export class SavedListViewService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); 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(); const id = require('crypto').randomUUID();
await knex('saved_list_views').insert({ await knex('saved_list_views').insert({
@@ -61,7 +96,6 @@ export class SavedListViewService {
const updates: Record<string, any> = { updated_at: knex.fn.now() }; const updates: Record<string, any> = { updated_at: knex.fn.now() };
if (dto.name !== undefined) updates.name = dto.name; 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.filters !== undefined) updates.filters = JSON.stringify(dto.filters);
if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null; if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null;
if (dto.description !== undefined) updates.description = dto.description; 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'); 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(); await knex('saved_list_views').where({ id }).delete();
return { deleted: true }; 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) { private deserialize(row: any, currentUserId: string) {
return { return {
id: row.id, id: row.id,

View File

@@ -186,6 +186,8 @@ interface Props {
objectApiName: string; objectApiName: string;
recordId: string; recordId: string;
ownerId?: string; ownerId?: string;
/** Optional base URL override for shares API. Defaults to /runtime/objects/{objectApiName}/records/{recordId}/shares */
basePath?: string;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -193,6 +195,11 @@ const props = defineProps<Props>();
const { api } = useApi(); const { api } = useApi();
const { toast } = useToast(); 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 loading = ref(true);
const sharing = ref(false); const sharing = ref(false);
const removing = ref<string | null>(null); const removing = ref<string | null>(null);
@@ -236,9 +243,7 @@ const loadShares = async () => {
try { try {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
const response = await api.get( const response = await api.get(sharesBasePath.value);
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
);
shares.value = response || []; shares.value = response || [];
} catch (e: any) { } catch (e: any) {
console.error('Failed to load shares:', e); console.error('Failed to load shares:', e);
@@ -286,7 +291,7 @@ const createShare = async () => {
console.log('Final payload:', payload); console.log('Final payload:', payload);
await api.post( await api.post(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`, sharesBasePath.value,
payload payload
); );
toast.success('Record shared successfully'); toast.success('Record shared successfully');
@@ -313,7 +318,7 @@ const removeShare = async (shareId: string) => {
try { try {
removing.value = shareId; removing.value = shareId;
await api.delete( await api.delete(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}` `${sharesBasePath.value}/${shareId}`
); );
toast.success('Share removed successfully'); toast.success('Share removed successfully');
await loadShares(); await loadShares();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, nextTick } from 'vue'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -11,9 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch' import { Pencil, Trash2, Users, Check, X, ChevronLeft } from 'lucide-vue-next'
import { Label } from '@/components/ui/label'
import { Pencil, Trash2, Users, Check, X } from 'lucide-vue-next'
import type { SavedView, UpdateSavedViewPayload } from '@/composables/useSavedViews' import type { SavedView, UpdateSavedViewPayload } from '@/composables/useSavedViews'
interface Props { interface Props {
@@ -38,8 +36,11 @@ const editingId = ref<string | null>(null)
const editName = ref('') const editName = ref('')
const deletingId = ref<string | null>(null) const deletingId = ref<string | null>(null)
// Sharing sub-view: when set, renders RecordSharing for this view
const sharingView = ref<SavedView | null>(null)
const ownViews = computed(() => props.views.filter(v => v.isOwner)) const ownViews = computed(() => props.views.filter(v => v.isOwner))
const sharedViews = computed(() => props.views.filter(v => !v.isOwner && v.isShared)) const sharedViews = computed(() => props.views.filter(v => !v.isOwner))
function startEdit(view: SavedView) { function startEdit(view: SavedView) {
editingId.value = view.id editingId.value = view.id
@@ -59,8 +60,12 @@ function commitEdit(view: SavedView) {
cancelEdit() cancelEdit()
} }
function toggleShare(view: SavedView) { function openSharing(view: SavedView) {
emit('update-view', view.id, { isShared: !view.isShared }) sharingView.value = view
}
function closeSharing() {
sharingView.value = null
} }
function confirmDelete(view: SavedView) { function confirmDelete(view: SavedView) {
@@ -79,11 +84,38 @@ function executeDelete(view: SavedView) {
<template> <template>
<Sheet :open="open" @update:open="emit('update:open', $event)"> <Sheet :open="open" @update:open="emit('update:open', $event)">
<SheetContent class="w-[420px] sm:w-[480px] overflow-y-auto"> <SheetContent class="w-[420px] sm:w-[520px] overflow-y-auto">
<!-- Sharing sub-view -->
<template v-if="sharingView">
<SheetHeader class="mb-4">
<div class="flex items-center gap-2">
<Button size="icon" variant="ghost" class="h-7 w-7 -ml-1" @click="closeSharing">
<ChevronLeft class="h-4 w-4" />
</Button>
<div>
<SheetTitle>Share "{{ sharingView.name }}"</SheetTitle>
<SheetDescription>
Grant access to specific users for this saved view.
</SheetDescription>
</div>
</div>
</SheetHeader>
<RecordSharing
object-api-name="SavedListView"
:record-id="sharingView.id"
:owner-id="sharingView.userId"
:base-path="`/saved-views/${sharingView.id}/shares`"
/>
</template>
<!-- Main view list -->
<template v-else>
<SheetHeader class="mb-4"> <SheetHeader class="mb-4">
<SheetTitle>{{ objectLabel }} Saved Views</SheetTitle> <SheetTitle>{{ objectLabel }} Saved Views</SheetTitle>
<SheetDescription> <SheetDescription>
Manage your saved searches. Shared views are visible to all users in your workspace. Manage your saved searches. Share views with specific users from your workspace.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -138,11 +170,6 @@ function executeDelete(view: SavedView) {
{{ view.name }} {{ view.name }}
</button> </button>
<!-- Shared badge -->
<Badge v-if="view.isShared" variant="secondary" class="text-[10px] px-1.5 py-0">
<Users class="h-3 w-3 mr-1" />Shared
</Badge>
<!-- Actions (visible on hover) --> <!-- Actions (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="icon" variant="ghost" class="h-6 w-6" title="Rename" @click="startEdit(view)"> <Button size="icon" variant="ghost" class="h-6 w-6" title="Rename" @click="startEdit(view)">
@@ -152,10 +179,10 @@ function executeDelete(view: SavedView) {
size="icon" size="icon"
variant="ghost" variant="ghost"
class="h-6 w-6" class="h-6 w-6"
:title="view.isShared ? 'Unshare' : 'Share with team'" title="Share"
@click="toggleShare(view)" @click="openSharing(view)"
> >
<Users class="h-3 w-3" :class="{ 'text-primary': view.isShared }" /> <Users class="h-3 w-3" />
</Button> </Button>
<Button size="icon" variant="ghost" class="h-6 w-6 text-destructive hover:text-destructive" title="Delete" @click="confirmDelete(view)"> <Button size="icon" variant="ghost" class="h-6 w-6 text-destructive hover:text-destructive" title="Delete" @click="confirmDelete(view)">
<Trash2 class="h-3 w-3" /> <Trash2 class="h-3 w-3" />
@@ -163,7 +190,7 @@ function executeDelete(view: SavedView) {
</div> </div>
</div> </div>
<!-- Description tooltip --> <!-- Description -->
<p <p
v-if="view.description && editingId !== view.id && deletingId !== view.id" v-if="view.description && editingId !== view.id && deletingId !== view.id"
class="text-xs text-muted-foreground mt-1 truncate" class="text-xs text-muted-foreground mt-1 truncate"
@@ -201,6 +228,7 @@ function executeDelete(view: SavedView) {
</ul> </ul>
</section> </section>
</template> </template>
</template>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</template> </template>

View File

@@ -37,7 +37,6 @@ export interface CreateSavedViewPayload {
export interface UpdateSavedViewPayload { export interface UpdateSavedViewPayload {
name?: string name?: string
isShared?: boolean
filters?: SavedViewFilter[] filters?: SavedViewFilter[]
sort?: SavedViewSort | null sort?: SavedViewSort | null
description?: string description?: string