mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user