WIP - sharing list views
This commit is contained in:
@@ -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();
|
||||
};
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Props>();
|
||||
@@ -193,6 +195,11 @@ const props = defineProps<Props>();
|
||||
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<string | null>(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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -11,9 +11,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Pencil, Trash2, Users, Check, X } from 'lucide-vue-next'
|
||||
import { Pencil, Trash2, Users, Check, X, ChevronLeft } from 'lucide-vue-next'
|
||||
import type { SavedView, UpdateSavedViewPayload } from '@/composables/useSavedViews'
|
||||
|
||||
interface Props {
|
||||
@@ -38,8 +36,11 @@ const editingId = ref<string | null>(null)
|
||||
const editName = ref('')
|
||||
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 sharedViews = computed(() => props.views.filter(v => !v.isOwner && v.isShared))
|
||||
const sharedViews = computed(() => props.views.filter(v => !v.isOwner))
|
||||
|
||||
function startEdit(view: SavedView) {
|
||||
editingId.value = view.id
|
||||
@@ -59,8 +60,12 @@ function commitEdit(view: SavedView) {
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
function toggleShare(view: SavedView) {
|
||||
emit('update-view', view.id, { isShared: !view.isShared })
|
||||
function openSharing(view: SavedView) {
|
||||
sharingView.value = view
|
||||
}
|
||||
|
||||
function closeSharing() {
|
||||
sharingView.value = null
|
||||
}
|
||||
|
||||
function confirmDelete(view: SavedView) {
|
||||
@@ -79,11 +84,38 @@ function executeDelete(view: SavedView) {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<SheetTitle>{{ objectLabel }} — Saved Views</SheetTitle>
|
||||
<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>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -138,11 +170,6 @@ function executeDelete(view: SavedView) {
|
||||
{{ view.name }}
|
||||
</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) -->
|
||||
<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)">
|
||||
@@ -152,10 +179,10 @@ function executeDelete(view: SavedView) {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-6 w-6"
|
||||
:title="view.isShared ? 'Unshare' : 'Share with team'"
|
||||
@click="toggleShare(view)"
|
||||
title="Share"
|
||||
@click="openSharing(view)"
|
||||
>
|
||||
<Users class="h-3 w-3" :class="{ 'text-primary': view.isShared }" />
|
||||
<Users class="h-3 w-3" />
|
||||
</Button>
|
||||
<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" />
|
||||
@@ -163,7 +190,7 @@ function executeDelete(view: SavedView) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description tooltip -->
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="view.description && editingId !== view.id && deletingId !== view.id"
|
||||
class="text-xs text-muted-foreground mt-1 truncate"
|
||||
@@ -201,6 +228,7 @@ function executeDelete(view: SavedView) {
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface CreateSavedViewPayload {
|
||||
|
||||
export interface UpdateSavedViewPayload {
|
||||
name?: string
|
||||
isShared?: boolean
|
||||
filters?: SavedViewFilter[]
|
||||
sort?: SavedViewSort | null
|
||||
description?: string
|
||||
|
||||
Reference in New Issue
Block a user