fix: Adapt SQL queries to actual Outline database schema

- Users: Use role enum instead of isAdmin/isViewer/isSuspended booleans
- Users: Remove non-existent username column
- Groups: Fix group_users table (no deletedAt, composite PK)
- Attachments: Remove url and deletedAt columns, use hard delete

All 10/10 core queries now pass validation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 13:32:41 +00:00
parent 42fc0c6d6d
commit 6f5d17516b
4 changed files with 65 additions and 61 deletions

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.0.1] - 2026-01-31
### Fixed
- **Users:** Adapted to Outline schema - use `role` enum instead of `isAdmin`/`isViewer`/`isSuspended` booleans
- **Users:** Removed non-existent `username` column
- **Groups:** Fixed `group_users` table queries - no `deletedAt` column, composite PK
- **Groups:** Fixed ambiguous column references in subqueries
- **Attachments:** Removed non-existent `url` and `deletedAt` columns
- **Attachments:** Changed delete to hard delete (no soft delete support)
### Changed
- Users suspend/activate now use `suspendedAt` column instead of boolean
- Groups member count uses correct join without deletedAt filter
## [1.0.0] - 2026-01-31 ## [1.0.0] - 2026-01-31
### Added ### Added

View File

@@ -46,7 +46,7 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
}, },
handler: async (args, pgClient): Promise<ToolResponse> => { handler: async (args, pgClient): Promise<ToolResponse> => {
const { limit, offset } = validatePagination(args.limit, args.offset); const { limit, offset } = validatePagination(args.limit, args.offset);
const conditions: string[] = ['a."deletedAt" IS NULL']; const conditions: string[] = [];
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
@@ -74,13 +74,12 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
params.push(args.team_id); params.push(args.team_id);
} }
const whereClause = `WHERE ${conditions.join(' AND ')}`; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = ` const query = `
SELECT SELECT
a.id, a.id,
a.key, a.key,
a.url,
a."contentType", a."contentType",
a.size, a.size,
a.acl, a.acl,
@@ -89,6 +88,8 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
a."teamId", a."teamId",
a."createdAt", a."createdAt",
a."updatedAt", a."updatedAt",
a."lastAccessedAt",
a."expiresAt",
d.title as "documentTitle", d.title as "documentTitle",
u.name as "uploadedByName", u.name as "uploadedByName",
u.email as "uploadedByEmail" u.email as "uploadedByEmail"
@@ -151,7 +152,6 @@ const getAttachment: BaseTool<GetAttachmentArgs> = {
SELECT SELECT
a.id, a.id,
a.key, a.key,
a.url,
a."contentType", a."contentType",
a.size, a.size,
a.acl, a.acl,
@@ -160,7 +160,8 @@ const getAttachment: BaseTool<GetAttachmentArgs> = {
a."teamId", a."teamId",
a."createdAt", a."createdAt",
a."updatedAt", a."updatedAt",
a."deletedAt", a."lastAccessedAt",
a."expiresAt",
d.title as "documentTitle", d.title as "documentTitle",
d."collectionId", d."collectionId",
u.name as "uploadedByName", u.name as "uploadedByName",
@@ -243,7 +244,7 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
// Get first admin user and team // Get first admin user and team
const userQuery = await pgClient.query( const userQuery = await pgClient.query(
'SELECT u.id, u."teamId" FROM users u WHERE u."isAdmin" = true AND u."deletedAt" IS NULL LIMIT 1' "SELECT u.id, u.\"teamId\" FROM users u WHERE u.role = 'admin' AND u.\"deletedAt\" IS NULL LIMIT 1"
); );
if (userQuery.rows.length === 0) { if (userQuery.rows.length === 0) {
@@ -253,14 +254,13 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
const userId = userQuery.rows[0].id; const userId = userQuery.rows[0].id;
const teamId = userQuery.rows[0].teamId; const teamId = userQuery.rows[0].teamId;
// Generate URL and key (in real implementation, this would be S3/storage URL) // Generate key (path in storage)
const key = `attachments/${Date.now()}-${args.name}`; const key = `attachments/${Date.now()}-${args.name}`;
const url = `/api/attachments.redirect?id=PLACEHOLDER`;
const query = ` const query = `
INSERT INTO attachments ( INSERT INTO attachments (
id,
key, key,
url,
"contentType", "contentType",
size, size,
acl, acl,
@@ -269,13 +269,12 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
"teamId", "teamId",
"createdAt", "createdAt",
"updatedAt" "updatedAt"
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) ) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING * RETURNING *
`; `;
const result = await pgClient.query(query, [ const result = await pgClient.query(query, [
key, key,
url,
args.content_type, args.content_type,
args.size, args.size,
'private', // Default ACL 'private', // Default ACL
@@ -306,7 +305,7 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
*/ */
const deleteAttachment: BaseTool<GetAttachmentArgs> = { const deleteAttachment: BaseTool<GetAttachmentArgs> = {
name: 'outline_attachments_delete', name: 'outline_attachments_delete',
description: 'Soft delete an attachment. The attachment record is marked as deleted but not removed from the database.', description: 'Delete an attachment permanently.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -323,18 +322,15 @@ const deleteAttachment: BaseTool<GetAttachmentArgs> = {
} }
const query = ` const query = `
UPDATE attachments DELETE FROM attachments
SET WHERE id = $1
"deletedAt" = NOW(),
"updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, key, "documentId" RETURNING id, key, "documentId"
`; `;
const result = await pgClient.query(query, [args.id]); const result = await pgClient.query(query, [args.id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('Attachment not found or already deleted'); throw new Error('Attachment not found');
} }
return { return {
@@ -376,7 +372,7 @@ const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> =
}, },
}, },
handler: async (args, pgClient): Promise<ToolResponse> => { handler: async (args, pgClient): Promise<ToolResponse> => {
const conditions: string[] = ['a."deletedAt" IS NULL']; const conditions: string[] = [];
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
@@ -396,7 +392,7 @@ const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> =
params.push(args.document_id); params.push(args.document_id);
} }
const whereClause = `WHERE ${conditions.join(' AND ')}`; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Overall statistics // Overall statistics
const overallStatsQuery = await pgClient.query( const overallStatsQuery = await pgClient.query(

View File

@@ -53,14 +53,15 @@ const listGroups: BaseTool<GroupArgs> = {
SELECT SELECT
g.id, g.id,
g.name, g.name,
g.description,
g."teamId", g."teamId",
g."createdById", g."createdById",
g."createdAt", g."createdAt",
g."updatedAt", g."updatedAt",
t.name as "teamName", t.name as "teamName",
u.name as "createdByName", u.name as "createdByName",
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount", (SELECT COUNT(*) FROM group_users gu WHERE gu."groupId" = g.id) as "memberCount",
(SELECT COUNT(*) FROM groups WHERE ${whereConditions.join(' AND ')}) as total (SELECT COUNT(*) FROM groups g2 WHERE g2."deletedAt" IS NULL) as total
FROM groups g FROM groups g
LEFT JOIN teams t ON g."teamId" = t.id LEFT JOIN teams t ON g."teamId" = t.id
LEFT JOIN users u ON g."createdById" = u.id LEFT JOIN users u ON g."createdById" = u.id
@@ -119,13 +120,14 @@ const getGroup: BaseTool<GetGroupArgs> = {
SELECT SELECT
g.id, g.id,
g.name, g.name,
g.description,
g."teamId", g."teamId",
g."createdById", g."createdById",
g."createdAt", g."createdAt",
g."updatedAt", g."updatedAt",
t.name as "teamName", t.name as "teamName",
u.name as "createdByName", u.name as "createdByName",
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount" (SELECT COUNT(*) FROM group_users gu WHERE gu."groupId" = g.id) as "memberCount"
FROM groups g FROM groups g
LEFT JOIN teams t ON g."teamId" = t.id LEFT JOIN teams t ON g."teamId" = t.id
LEFT JOIN users u ON g."createdById" = u.id LEFT JOIN users u ON g."createdById" = u.id
@@ -183,7 +185,7 @@ const createGroup: BaseTool<CreateGroupArgs> = {
// Get first admin user as creator (adjust as needed) // Get first admin user as creator (adjust as needed)
const userResult = await pgClient.query( const userResult = await pgClient.query(
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1` `SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
); );
if (userResult.rows.length === 0) { if (userResult.rows.length === 0) {
throw new Error('No admin user found'); throw new Error('No admin user found');
@@ -359,19 +361,19 @@ const listGroupMembers: BaseTool<GetGroupArgs> = {
const result = await pgClient.query( const result = await pgClient.query(
` `
SELECT SELECT
gu.id as "membershipId",
gu."userId", gu."userId",
gu."groupId", gu."groupId",
gu."createdById", gu."createdById",
gu."createdAt", gu."createdAt",
gu.permission,
u.name as "userName", u.name as "userName",
u.email as "userEmail", u.email as "userEmail",
u."isAdmin" as "userIsAdmin", u.role as "userRole",
creator.name as "addedByName" creator.name as "addedByName"
FROM group_users gu FROM group_users gu
JOIN users u ON gu."userId" = u.id JOIN users u ON gu."userId" = u.id
LEFT JOIN users creator ON gu."createdById" = creator.id LEFT JOIN users creator ON gu."createdById" = creator.id
WHERE gu."groupId" = $1 AND gu."deletedAt" IS NULL AND u."deletedAt" IS NULL WHERE gu."groupId" = $1 AND u."deletedAt" IS NULL
ORDER BY gu."createdAt" DESC ORDER BY gu."createdAt" DESC
`, `,
[args.id] [args.id]
@@ -445,7 +447,7 @@ const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
// Check if user is already in group // Check if user is already in group
const existingMembership = await pgClient.query( const existingMembership = await pgClient.query(
`SELECT id FROM group_users WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL`, `SELECT "userId" FROM group_users WHERE "groupId" = $1 AND "userId" = $2`,
[args.id, args.user_id] [args.id, args.user_id]
); );
if (existingMembership.rows.length > 0) { if (existingMembership.rows.length > 0) {
@@ -454,18 +456,18 @@ const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
// Get first admin user as creator (adjust as needed) // Get first admin user as creator (adjust as needed)
const creatorResult = await pgClient.query( const creatorResult = await pgClient.query(
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1` `SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
); );
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id; const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
const result = await pgClient.query( const result = await pgClient.query(
` `
INSERT INTO group_users ( INSERT INTO group_users (
id, "userId", "groupId", "createdById", "userId", "groupId", "createdById",
"createdAt", "updatedAt" "createdAt", "updatedAt"
) )
VALUES ( VALUES (
gen_random_uuid(), $1, $2, $3, $1, $2, $3,
NOW(), NOW() NOW(), NOW()
) )
RETURNING * RETURNING *
@@ -521,10 +523,9 @@ const removeUserFromGroup: BaseTool<{ id: string; user_id: string }> = {
const result = await pgClient.query( const result = await pgClient.query(
` `
UPDATE group_users DELETE FROM group_users
SET "deletedAt" = NOW() WHERE "groupId" = $1 AND "userId" = $2
WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL RETURNING "userId", "groupId"
RETURNING id, "userId", "groupId"
`, `,
[args.id, args.user_id] [args.id, args.user_id]
); );

View File

@@ -56,13 +56,13 @@ const listUsers: BaseTool<UserArgs> = {
// Add role/status filters // Add role/status filters
switch (filter) { switch (filter) {
case 'admins': case 'admins':
whereConditions.push('u."isAdmin" = true'); whereConditions.push("u.role = 'admin'");
break; break;
case 'members': case 'members':
whereConditions.push('u."isAdmin" = false AND u."isViewer" = false'); whereConditions.push("u.role = 'member'");
break; break;
case 'suspended': case 'suspended':
whereConditions.push('u."isSuspended" = true'); whereConditions.push('u."suspendedAt" IS NOT NULL');
break; break;
case 'invited': case 'invited':
whereConditions.push('u."lastSignedInAt" IS NULL'); whereConditions.push('u."lastSignedInAt" IS NULL');
@@ -76,16 +76,13 @@ const listUsers: BaseTool<UserArgs> = {
SELECT SELECT
u.id, u.id,
u.email, u.email,
u.username,
u.name, u.name,
u."avatarUrl", u."avatarUrl",
u.language, u.language,
u.preferences, u.preferences,
u."notificationSettings", u."notificationSettings",
u.timezone, u.timezone,
u."isAdmin", u.role,
u."isViewer",
u."isSuspended",
u."lastActiveAt", u."lastActiveAt",
u."lastSignedInAt", u."lastSignedInAt",
u."suspendedAt", u."suspendedAt",
@@ -94,7 +91,7 @@ const listUsers: BaseTool<UserArgs> = {
u."createdAt", u."createdAt",
u."updatedAt", u."updatedAt",
t.name as "teamName", t.name as "teamName",
(SELECT COUNT(*) FROM users WHERE ${whereConditions.join(' AND ')}) as total (SELECT COUNT(*) FROM users u2 WHERE u2."deletedAt" IS NULL) as total
FROM users u FROM users u
LEFT JOIN teams t ON u."teamId" = t.id LEFT JOIN teams t ON u."teamId" = t.id
${whereClause} ${whereClause}
@@ -152,16 +149,13 @@ const getUser: BaseTool<GetUserArgs> = {
SELECT SELECT
u.id, u.id,
u.email, u.email,
u.username,
u.name, u.name,
u."avatarUrl", u."avatarUrl",
u.language, u.language,
u.preferences, u.preferences,
u."notificationSettings", u."notificationSettings",
u.timezone, u.timezone,
u."isAdmin", u.role,
u."isViewer",
u."isSuspended",
u."lastActiveAt", u."lastActiveAt",
u."lastSignedInAt", u."lastSignedInAt",
u."suspendedAt", u."suspendedAt",
@@ -254,22 +248,19 @@ const createUser: BaseTool<CreateUserArgs> = {
} }
const teamId = teamResult.rows[0].id; const teamId = teamResult.rows[0].id;
const isAdmin = role === 'admin';
const isViewer = role === 'viewer';
const result = await pgClient.query( const result = await pgClient.query(
` `
INSERT INTO users ( INSERT INTO users (
id, email, name, "teamId", "isAdmin", "isViewer", id, email, name, "teamId", role,
"createdAt", "updatedAt" "createdAt", "updatedAt"
) )
VALUES ( VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, gen_random_uuid(), $1, $2, $3, $4,
NOW(), NOW() NOW(), NOW()
) )
RETURNING * RETURNING *
`, `,
[email, name, teamId, isAdmin, isViewer] [email, name, teamId, role]
); );
return { return {
@@ -458,9 +449,9 @@ const suspendUser: BaseTool<GetUserArgs> = {
const result = await pgClient.query( const result = await pgClient.query(
` `
UPDATE users UPDATE users
SET "isSuspended" = true, "suspendedAt" = NOW() SET "suspendedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, email, name, "isSuspended", "suspendedAt" RETURNING id, email, name, "suspendedAt"
`, `,
[args.id] [args.id]
); );
@@ -511,9 +502,9 @@ const activateUser: BaseTool<GetUserArgs> = {
const result = await pgClient.query( const result = await pgClient.query(
` `
UPDATE users UPDATE users
SET "isSuspended" = false, "suspendedAt" = NULL, "suspendedById" = NULL SET "suspendedAt" = NULL, "suspendedById" = NULL
WHERE id = $1 AND "deletedAt" IS NULL WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, email, name, "isSuspended" RETURNING id, email, name, "suspendedAt"
`, `,
[args.id] [args.id]
); );
@@ -564,9 +555,9 @@ const promoteUser: BaseTool<GetUserArgs> = {
const result = await pgClient.query( const result = await pgClient.query(
` `
UPDATE users UPDATE users
SET "isAdmin" = true, "isViewer" = false, "updatedAt" = NOW() SET role = 'admin', "updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, email, name, "isAdmin", "isViewer" RETURNING id, email, name, role
`, `,
[args.id] [args.id]
); );
@@ -617,9 +608,9 @@ const demoteUser: BaseTool<GetUserArgs> = {
const result = await pgClient.query( const result = await pgClient.query(
` `
UPDATE users UPDATE users
SET "isAdmin" = false, "updatedAt" = NOW() SET role = 'member', "updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, email, name, "isAdmin", "isViewer" RETURNING id, email, name, role
`, `,
[args.id] [args.id]
); );