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