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:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,7 +46,7 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['a."deletedAt" IS NULL'];
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
@@ -74,13 +74,12 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
|
||||
params.push(args.team_id);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
a.id,
|
||||
a.key,
|
||||
a.url,
|
||||
a."contentType",
|
||||
a.size,
|
||||
a.acl,
|
||||
@@ -89,6 +88,8 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
|
||||
a."teamId",
|
||||
a."createdAt",
|
||||
a."updatedAt",
|
||||
a."lastAccessedAt",
|
||||
a."expiresAt",
|
||||
d.title as "documentTitle",
|
||||
u.name as "uploadedByName",
|
||||
u.email as "uploadedByEmail"
|
||||
@@ -151,7 +152,6 @@ const getAttachment: BaseTool<GetAttachmentArgs> = {
|
||||
SELECT
|
||||
a.id,
|
||||
a.key,
|
||||
a.url,
|
||||
a."contentType",
|
||||
a.size,
|
||||
a.acl,
|
||||
@@ -160,7 +160,8 @@ const getAttachment: BaseTool<GetAttachmentArgs> = {
|
||||
a."teamId",
|
||||
a."createdAt",
|
||||
a."updatedAt",
|
||||
a."deletedAt",
|
||||
a."lastAccessedAt",
|
||||
a."expiresAt",
|
||||
d.title as "documentTitle",
|
||||
d."collectionId",
|
||||
u.name as "uploadedByName",
|
||||
@@ -243,7 +244,7 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
||||
|
||||
// Get first admin user and team
|
||||
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) {
|
||||
@@ -253,14 +254,13 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
||||
const userId = userQuery.rows[0].id;
|
||||
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 url = `/api/attachments.redirect?id=PLACEHOLDER`;
|
||||
|
||||
const query = `
|
||||
INSERT INTO attachments (
|
||||
id,
|
||||
key,
|
||||
url,
|
||||
"contentType",
|
||||
size,
|
||||
acl,
|
||||
@@ -269,13 +269,12 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
||||
"teamId",
|
||||
"createdAt",
|
||||
"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 *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [
|
||||
key,
|
||||
url,
|
||||
args.content_type,
|
||||
args.size,
|
||||
'private', // Default ACL
|
||||
@@ -306,7 +305,7 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
||||
*/
|
||||
const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -323,18 +322,15 @@ const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE attachments
|
||||
SET
|
||||
"deletedAt" = NOW(),
|
||||
"updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
DELETE FROM attachments
|
||||
WHERE id = $1
|
||||
RETURNING id, key, "documentId"
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Attachment not found or already deleted');
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -376,7 +372,7 @@ const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> =
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const conditions: string[] = ['a."deletedAt" IS NULL'];
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
@@ -396,7 +392,7 @@ const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> =
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Overall statistics
|
||||
const overallStatsQuery = await pgClient.query(
|
||||
|
||||
@@ -53,14 +53,15 @@ const listGroups: BaseTool<GroupArgs> = {
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g."teamId",
|
||||
g."createdById",
|
||||
g."createdAt",
|
||||
g."updatedAt",
|
||||
t.name as "teamName",
|
||||
u.name as "createdByName",
|
||||
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount",
|
||||
(SELECT COUNT(*) FROM groups WHERE ${whereConditions.join(' AND ')}) as total
|
||||
(SELECT COUNT(*) FROM group_users gu WHERE gu."groupId" = g.id) as "memberCount",
|
||||
(SELECT COUNT(*) FROM groups g2 WHERE g2."deletedAt" IS NULL) as total
|
||||
FROM groups g
|
||||
LEFT JOIN teams t ON g."teamId" = t.id
|
||||
LEFT JOIN users u ON g."createdById" = u.id
|
||||
@@ -119,13 +120,14 @@ const getGroup: BaseTool<GetGroupArgs> = {
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g."teamId",
|
||||
g."createdById",
|
||||
g."createdAt",
|
||||
g."updatedAt",
|
||||
t.name as "teamName",
|
||||
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
|
||||
LEFT JOIN teams t ON g."teamId" = t.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)
|
||||
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) {
|
||||
throw new Error('No admin user found');
|
||||
@@ -359,19 +361,19 @@ const listGroupMembers: BaseTool<GetGroupArgs> = {
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
gu.id as "membershipId",
|
||||
gu."userId",
|
||||
gu."groupId",
|
||||
gu."createdById",
|
||||
gu."createdAt",
|
||||
gu.permission,
|
||||
u.name as "userName",
|
||||
u.email as "userEmail",
|
||||
u."isAdmin" as "userIsAdmin",
|
||||
u.role as "userRole",
|
||||
creator.name as "addedByName"
|
||||
FROM group_users gu
|
||||
JOIN users u ON gu."userId" = u.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
|
||||
`,
|
||||
[args.id]
|
||||
@@ -445,7 +447,7 @@ const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
|
||||
|
||||
// Check if user is already in group
|
||||
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]
|
||||
);
|
||||
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)
|
||||
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 result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO group_users (
|
||||
id, "userId", "groupId", "createdById",
|
||||
"userId", "groupId", "createdById",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), $1, $2, $3,
|
||||
$1, $2, $3,
|
||||
NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
@@ -521,10 +523,9 @@ const removeUserFromGroup: BaseTool<{ id: string; user_id: string }> = {
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE group_users
|
||||
SET "deletedAt" = NOW()
|
||||
WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL
|
||||
RETURNING id, "userId", "groupId"
|
||||
DELETE FROM group_users
|
||||
WHERE "groupId" = $1 AND "userId" = $2
|
||||
RETURNING "userId", "groupId"
|
||||
`,
|
||||
[args.id, args.user_id]
|
||||
);
|
||||
|
||||
@@ -56,13 +56,13 @@ const listUsers: BaseTool<UserArgs> = {
|
||||
// Add role/status filters
|
||||
switch (filter) {
|
||||
case 'admins':
|
||||
whereConditions.push('u."isAdmin" = true');
|
||||
whereConditions.push("u.role = 'admin'");
|
||||
break;
|
||||
case 'members':
|
||||
whereConditions.push('u."isAdmin" = false AND u."isViewer" = false');
|
||||
whereConditions.push("u.role = 'member'");
|
||||
break;
|
||||
case 'suspended':
|
||||
whereConditions.push('u."isSuspended" = true');
|
||||
whereConditions.push('u."suspendedAt" IS NOT NULL');
|
||||
break;
|
||||
case 'invited':
|
||||
whereConditions.push('u."lastSignedInAt" IS NULL');
|
||||
@@ -76,16 +76,13 @@ const listUsers: BaseTool<UserArgs> = {
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.username,
|
||||
u.name,
|
||||
u."avatarUrl",
|
||||
u.language,
|
||||
u.preferences,
|
||||
u."notificationSettings",
|
||||
u.timezone,
|
||||
u."isAdmin",
|
||||
u."isViewer",
|
||||
u."isSuspended",
|
||||
u.role,
|
||||
u."lastActiveAt",
|
||||
u."lastSignedInAt",
|
||||
u."suspendedAt",
|
||||
@@ -94,7 +91,7 @@ const listUsers: BaseTool<UserArgs> = {
|
||||
u."createdAt",
|
||||
u."updatedAt",
|
||||
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
|
||||
LEFT JOIN teams t ON u."teamId" = t.id
|
||||
${whereClause}
|
||||
@@ -152,16 +149,13 @@ const getUser: BaseTool<GetUserArgs> = {
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.username,
|
||||
u.name,
|
||||
u."avatarUrl",
|
||||
u.language,
|
||||
u.preferences,
|
||||
u."notificationSettings",
|
||||
u.timezone,
|
||||
u."isAdmin",
|
||||
u."isViewer",
|
||||
u."isSuspended",
|
||||
u.role,
|
||||
u."lastActiveAt",
|
||||
u."lastSignedInAt",
|
||||
u."suspendedAt",
|
||||
@@ -254,22 +248,19 @@ const createUser: BaseTool<CreateUserArgs> = {
|
||||
}
|
||||
const teamId = teamResult.rows[0].id;
|
||||
|
||||
const isAdmin = role === 'admin';
|
||||
const isViewer = role === 'viewer';
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO users (
|
||||
id, email, name, "teamId", "isAdmin", "isViewer",
|
||||
id, email, name, "teamId", role,
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), $1, $2, $3, $4, $5,
|
||||
gen_random_uuid(), $1, $2, $3, $4,
|
||||
NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
`,
|
||||
[email, name, teamId, isAdmin, isViewer]
|
||||
[email, name, teamId, role]
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -458,9 +449,9 @@ const suspendUser: BaseTool<GetUserArgs> = {
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isSuspended" = true, "suspendedAt" = NOW()
|
||||
SET "suspendedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isSuspended", "suspendedAt"
|
||||
RETURNING id, email, name, "suspendedAt"
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
@@ -511,9 +502,9 @@ const activateUser: BaseTool<GetUserArgs> = {
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isSuspended" = false, "suspendedAt" = NULL, "suspendedById" = NULL
|
||||
SET "suspendedAt" = NULL, "suspendedById" = NULL
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isSuspended"
|
||||
RETURNING id, email, name, "suspendedAt"
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
@@ -564,9 +555,9 @@ const promoteUser: BaseTool<GetUserArgs> = {
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isAdmin" = true, "isViewer" = false, "updatedAt" = NOW()
|
||||
SET role = 'admin', "updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isAdmin", "isViewer"
|
||||
RETURNING id, email, name, role
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
@@ -617,9 +608,9 @@ const demoteUser: BaseTool<GetUserArgs> = {
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isAdmin" = false, "updatedAt" = NOW()
|
||||
SET role = 'member', "updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isAdmin", "isViewer"
|
||||
RETURNING id, email, name, role
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user