Add relationships API endpoints (#9402)

* Migrate Backlink model to generic Relationship model

- Create new Relationship model with type field to support different relationship types
- Add database migration to create relationships table and migrate existing backlinks
- Update Backlink model to delegate to Relationship model for backward compatibility
- Update BacklinksProcessor to use Relationship model with backlink type
- Update API routes to use new Relationship model
- Update test files to use Relationship model
- Maintain backward compatibility through database view and model delegation

Fixes #9366

* Update migration to rename table instead of creating new one

- Rename existing backlinks table to relationships instead of creating new table
- Add type column with default value to existing table
- Update existing rows to have type='backlink'
- Avoid expensive data migration by keeping existing data in place
- Maintain backward compatibility with database view
- Update rollback to reverse table rename and column addition

This approach is much more efficient for large datasets as it avoids copying millions of rows.

* Remove unnecessary UPDATE statement from migration

The UPDATE statement is not needed since defaultValue automatically
applies to existing rows when adding a column with a default value.

Thanks @tommoor for catching this!

* Wrap up migration in transaction

- Wrap all migration operations in a transaction for atomicity
- Add transaction parameter to all queryInterface calls
- Follow the same pattern as other migrations in the codebase
- Ensures all operations succeed or fail together

* Remove Backlink class entirely and use Relationship everywhere

- Delete server/models/Backlink.ts
- Remove Backlink export from server/models/index.ts
- Remove Backlink import and association from Document model
- All functionality now uses Relationship model with RelationshipType.Backlink
- Maintains same API through Relationship model methods
- Cleaner architecture with single relationship model

* Update documents.test.ts to use RelationshipType enum instead of string

- Import RelationshipType from Relationship model
- Replace type: "backlink" with type: RelationshipType.Backlink
- Improves type safety and consistency with enum usage

* Address code review feedback

- Add transaction wrapper to migration down method for safer rollback
- Remove unused findByTypeForUser method from Relationship model
- Method wasn't used and won't work for all relationship types (e.g., user mentions)
- Clean up code structure and improve safety

* Restore imports

* Add relationships API endpoints

- Create relationships API following stars pattern
- Add CRUD operations: create, list, delete
- Include proper validation, authentication, and authorization
- Support filtering by relationship type and document IDs
- Add relationship presenter and policies
- Register routes in main API router

* Remove relationships.create and relationships.delete endpoints

- Keep only relationships.list endpoint as requested
- Remove create and delete schemas from validation
- Update policies to only allow read operations
- Relationships will be managed internally, not via external API

* Add relationships.info endpoint

- Use Document.findByPk for authorization as requested
- Find relationship by ID and verify user has access to related document
- Return relationship details with accessible documents
- Include proper validation schema for UUID parameter

* Update 20250601223331-migrate-backlink-to-relationship.js

* Update Relationship.ts

* wip

* test

* Final tweaks

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-08-10 15:24:01 -04:00
committed by GitHub
parent c5cd4d9335
commit c3f93a3e9d
12 changed files with 608 additions and 11 deletions
+8 -9
View File
@@ -25,15 +25,14 @@ jobs:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
lint:
needs: build
@@ -14,7 +14,7 @@ module.exports = {
"relationships",
"type",
{
type: Sequelize.ENUM("backlink"),
type: Sequelize.ENUM("backlink", "similar"),
allowNull: false,
defaultValue: "backlink",
},
+2
View File
@@ -13,6 +13,7 @@ import Fix from "./decorators/Fix";
export enum RelationshipType {
Backlink = "backlink",
Similar = "similar",
}
@Table({ tableName: "relationships", modelName: "relationship" })
@@ -54,6 +55,7 @@ class Relationship extends IdModel<
*
* @param documentId The document ID to find backlinks for
* @param user The user to check access for
* @deprecated
*/
public static async findSourceDocumentIdsForUser(
documentId: string,
+2
View File
@@ -19,6 +19,7 @@ import presentPolicies from "./policy";
import presentProviderConfig from "./providerConfig";
import presentPublicTeam from "./publicTeam";
import presentReaction from "./reaction";
import presentRelationship from "./relationship";
import presentRevision from "./revision";
import presentSearchQuery from "./searchQuery";
import presentShare from "./share";
@@ -51,6 +52,7 @@ export {
presentPolicies,
presentProviderConfig,
presentReaction,
presentRelationship,
presentRevision,
presentSearchQuery,
presentShare,
+13
View File
@@ -0,0 +1,13 @@
import { Relationship } from "@server/models";
export default function presentRelationship(relationship: Relationship) {
return {
id: relationship.id,
type: relationship.type,
documentId: relationship.documentId,
reverseDocumentId: relationship.reverseDocumentId,
userId: relationship.userId,
createdAt: relationship.createdAt,
updatedAt: relationship.updatedAt,
};
}
+2
View File
@@ -32,6 +32,7 @@ import oauthAuthentications from "./oauthAuthentications";
import oauthClients from "./oauthClients";
import pins from "./pins";
import reactions from "./reactions";
import relationships from "./relationships";
import revisions from "./revisions";
import searches from "./searches";
import shares from "./shares";
@@ -102,6 +103,7 @@ router.use("/", fileOperationsRoute.routes());
router.use("/", urls.routes());
router.use("/", userMemberships.routes());
router.use("/", reactions.routes());
router.use("/", relationships.routes());
router.use("/", imports.routes());
if (!env.isCloudHosted) {
+1
View File
@@ -0,0 +1 @@
export { default } from "./relationships";
@@ -0,0 +1,418 @@
import { CollectionPermission } from "@shared/types";
import { Document, Relationship, User } from "@server/models";
import { RelationshipType } from "@server/models/Relationship";
import {
buildAdmin,
buildCollection,
buildDocument,
buildRelationship,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#relationships.info", () => {
let admin: User;
let user: User;
let anotherUser: User;
let document: Document;
let reverseDocument: Document;
let relationship: Relationship;
beforeEach(async () => {
admin = await buildAdmin();
[user, anotherUser] = await Promise.all([
buildUser({ teamId: admin.teamId }),
buildUser(),
]);
document = await buildDocument({
createdById: admin.id,
teamId: admin.teamId,
});
reverseDocument = await buildDocument({
createdById: admin.id,
teamId: admin.teamId,
});
relationship = await buildRelationship({
userId: admin.id,
documentId: document.id,
reverseDocumentId: reverseDocument.id,
type: RelationshipType.Backlink,
});
});
it("should fail with status 401 unauthorized when user token is missing", async () => {
const res = await server.post("/api/relationships.info", {
body: {
id: relationship.id,
},
});
expect(res.status).toEqual(401);
});
it("should fail with status 400 bad request when id is not supplied", async () => {
const res = await server.post("/api/relationships.info", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should fail with status 400 bad request when id is not a valid UUID", async () => {
const res = await server.post("/api/relationships.info", {
body: {
token: user.getJwtToken(),
id: "invalid-uuid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Invalid uuid");
});
it("should fail with status 404 not found when relationship does not exist", async () => {
const res = await server.post("/api/relationships.info", {
body: {
token: admin.getJwtToken(),
id: "550e8400-e29b-41d4-a716-446655440000",
},
});
const body = await res.json();
expect(res.status).toEqual(404);
expect(body.message).toEqual("Resource not found");
});
it("should fail with status 403 forbidden when user cannot read the document", async () => {
const res = await server.post("/api/relationships.info", {
body: {
token: anotherUser.getJwtToken(),
id: relationship.id,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Authorization error");
});
it("should succeed with status 200 ok when user can read the document", async () => {
const res = await server.post("/api/relationships.info", {
body: {
token: admin.getJwtToken(),
id: relationship.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationship).toBeTruthy();
expect(body.data.relationship.id).toEqual(relationship.id);
expect(body.data.relationship.documentId).toEqual(document.id);
expect(body.data.relationship.reverseDocumentId).toEqual(
reverseDocument.id
);
expect(body.data.relationship.type).toEqual(RelationshipType.Backlink);
expect(body.data.documents).toBeTruthy();
expect(body.data.documents).toHaveLength(2);
expect(body.policies).toBeTruthy();
});
it("should succeed with status 200 ok when user can read document but not reverse document", async () => {
// Create a relationship where user can read main document but not reverse document
const userDocument = await buildDocument({
createdById: user.id,
teamId: user.teamId,
});
const adminDocument = await buildDocument({
createdById: admin.id,
teamId: admin.teamId,
});
const userRelationship = await buildRelationship({
userId: user.id,
documentId: userDocument.id,
reverseDocumentId: adminDocument.id,
});
const res = await server.post("/api/relationships.info", {
body: {
token: user.getJwtToken(),
id: userRelationship.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationship).toBeTruthy();
expect(body.data.documents).toHaveLength(2);
// User can read their own document but admin document should also be included
const documentIds = body.data.documents.map((doc: any) => doc.id);
expect(documentIds).toContain(userDocument.id);
});
it("should include both documents when user can read both", async () => {
// Make user team member so they can read both documents
const teamUser = await buildUser({ teamId: admin.teamId });
const res = await server.post("/api/relationships.info", {
body: {
token: teamUser.getJwtToken(),
id: relationship.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents).toHaveLength(2);
const documentIds = body.data.documents.map((doc: any) => doc.id);
expect(documentIds).toContain(document.id);
expect(documentIds).toContain(reverseDocument.id);
});
});
describe("#relationships.list", () => {
let admin: User;
let user: User;
let anotherUser: User; // eslint-disable-line @typescript-eslint/no-unused-vars
let relationships: Relationship[]; // eslint-disable-line @typescript-eslint/no-unused-vars
let documents: Document[];
beforeEach(async () => {
admin = await buildAdmin();
[user, anotherUser] = await Promise.all([
buildUser({ teamId: admin.teamId }),
buildUser(),
]);
documents = await Promise.all([
buildDocument({
createdById: admin.id,
teamId: admin.teamId,
}),
buildDocument({
createdById: admin.id,
teamId: admin.teamId,
}),
buildDocument({
createdById: admin.id,
teamId: admin.teamId,
}),
]);
relationships = [
await buildRelationship({
userId: admin.id,
documentId: documents[0].id,
reverseDocumentId: documents[1].id,
type: RelationshipType.Backlink,
}),
await buildRelationship({
userId: admin.id,
documentId: documents[1].id,
reverseDocumentId: documents[2].id,
type: RelationshipType.Similar,
}),
await buildRelationship({
userId: admin.id,
documentId: documents[2].id,
reverseDocumentId: documents[0].id,
type: RelationshipType.Backlink,
}),
];
});
it("should fail with status 401 unauthorized when user token is missing", async () => {
const res = await server.post("/api/relationships.list", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body.message).toEqual("Authentication required");
});
it("should succeed with status 200 ok returning all relationships", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationships).toBeTruthy();
expect(body.data.relationships.length).toBeGreaterThanOrEqual(3);
expect(body.data.documents).toBeTruthy();
expect(body.pagination).toBeTruthy();
expect(body.policies).toBeTruthy();
});
it("should succeed with status 200 ok returning relationships filtered by type", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
type: RelationshipType.Backlink,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationships).toBeTruthy();
// All returned relationships should be backlinks
body.data.relationships.forEach((rel: any) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
});
});
it("should succeed with status 200 ok returning relationships filtered by documentId", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
documentId: documents[0].id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified documentId
body.data.relationships.forEach((rel: any) => {
expect(rel.documentId).toEqual(documents[0].id);
});
});
it("should succeed with status 200 ok returning relationships filtered by reverseDocumentId", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
reverseDocumentId: documents[1].id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified reverseDocumentId
body.data.relationships.forEach((rel: any) => {
expect(rel.reverseDocumentId).toEqual(documents[1].id);
});
});
it("should succeed with status 200 ok returning relationships with multiple filters", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
type: RelationshipType.Backlink,
documentId: documents[0].id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.relationships).toBeTruthy();
// All returned relationships should match both filters
body.data.relationships.forEach((rel: any) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
});
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
documentId: "invalid-id",
},
});
expect(res.status).toEqual(400);
});
it("should fail with status 400 bad request when reverseDocumentId is invalid", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
reverseDocumentId: "invalid-id",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toContain("uuid or url slug");
});
it("should respect pagination", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
limit: 1,
offset: 0,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.relationships).toHaveLength(1);
expect(body.pagination).toBeTruthy();
expect(body.pagination.limit).toEqual(1);
expect(body.pagination.offset).toEqual(0);
});
it("should return empty results when no relationships match filters", async () => {
const res = await server.post("/api/relationships.list", {
body: {
token: admin.getJwtToken(),
documentId: "550e8400-e29b-41d4-a716-446655440000",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.relationships).toHaveLength(0);
expect(body.data.documents).toHaveLength(0);
});
it("should only return documents that the user can read", async () => {
// Create a relationship where user can only read some documents
const cannotAccessCollection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const collection = await buildCollection({
teamId: user.teamId,
permission: CollectionPermission.Read,
});
const userDocument = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const cannotAccessDocument = await buildDocument({
collectionId: cannotAccessCollection.id,
teamId: admin.teamId,
});
await buildRelationship({
userId: user.id,
documentId: userDocument.id,
reverseDocumentId: cannotAccessDocument.id,
});
const res = await server.post("/api/relationships.list", {
body: {
token: user.getJwtToken(),
documentId: userDocument.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.relationships).toHaveLength(1);
expect(body.data.documents).toHaveLength(0);
});
});
@@ -0,0 +1,98 @@
import Router from "koa-router";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Document, Relationship } from "@server/models";
import { authorize, can } from "@server/policies";
import {
presentRelationship,
presentDocument,
presentPolicies,
} from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"relationships.info",
auth(),
validate(T.RelationshipsInfoSchema),
async (ctx: APIContext<T.RelationshipsInfoReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const relationship = await Relationship.findByPk(id, {
rejectOnEmpty: true,
});
const document = await Document.findByPk(relationship.documentId, {
userId: user.id,
rejectOnEmpty: true,
});
authorize(user, "read", document);
const reverseDocument = await Document.findByPk(
relationship.reverseDocumentId,
{
userId: user.id,
rejectOnEmpty: true,
}
);
authorize(user, "read", reverseDocument);
const documents = [document, reverseDocument];
ctx.body = {
data: {
relationship: presentRelationship(relationship),
documents: await Promise.all(
documents.map((doc: Document) => presentDocument(ctx, doc))
),
},
policies: presentPolicies(user, documents),
};
}
);
router.post(
"relationships.list",
auth(),
pagination(),
validate(T.RelationshipsListSchema),
async (ctx: APIContext<T.RelationshipsListReq>) => {
const { user } = ctx.state.auth;
const where = ctx.input.body || {};
const relationships = await Relationship.findAll({
where,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const documents = await Document.findByIds(
relationships.flatMap((relationship) =>
where.reverseDocumentId
? relationship.documentId
: relationship.reverseDocumentId
),
{ userId: user.id }
);
const policies = presentPolicies(user, [...documents, ...relationships]);
ctx.body = {
pagination: ctx.state.pagination,
data: {
relationships: relationships.map(presentRelationship),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(ctx, document))
),
policies: presentPolicies(user, documents),
},
policies,
};
}
);
export default router;
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod";
import { RelationshipType } from "@server/models/Relationship";
import { ValidateDocumentId } from "@server/validation";
import { BaseSchema } from "../schema";
export const RelationshipsInfoSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
}),
});
export type RelationshipsInfoReq = z.infer<typeof RelationshipsInfoSchema>;
export const RelationshipsListSchema = BaseSchema.extend({
body: z
.object({
type: z.nativeEnum(RelationshipType).optional(),
documentId: z
.string()
.refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
})
.optional(),
reverseDocumentId: z
.string()
.refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
})
.optional(),
})
.optional(),
});
export type RelationshipsListReq = z.infer<typeof RelationshipsListSchema>;
+28
View File
@@ -47,7 +47,9 @@ import {
OAuthClient,
AuthenticationProvider,
OAuthAuthentication,
Relationship,
} from "@server/models";
import { RelationshipType } from "@server/models/Relationship";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { hash } from "@server/utils/crypto";
import { OAuthInterface } from "@server/utils/oauth/OAuthInterface";
@@ -829,3 +831,29 @@ export function buildCommentMark(overrides: {
attrs: overrides,
};
}
export async function buildRelationship(overrides: Partial<Relationship> = {}) {
if (!overrides.userId) {
const user = await buildUser();
overrides.userId = user.id;
}
if (!overrides.documentId) {
const document = await buildDocument({
createdById: overrides.userId,
});
overrides.documentId = document.id;
}
if (!overrides.reverseDocumentId) {
const reverseDocument = await buildDocument({
createdById: overrides.userId,
});
overrides.reverseDocumentId = reverseDocument.id;
}
return Relationship.create({
type: RelationshipType.Backlink,
...overrides,
});
}
+1 -1
View File
@@ -7704,7 +7704,7 @@ ejs@^3.1.10, ejs@^3.1.6:
dependencies:
jake "^10.8.5"
electron-to-chromium@^1.5.173:
electron-to-chromium@^1.5.173, electron-to-chromium@^1.5.73:
version "1.5.182"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz#4ab73104f893938acb3ab9c28d7bec170c116b3e"
integrity sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==