mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b6e47e1bc | |||
| b7957a607d | |||
| fe7182d034 | |||
| 8610101377 | |||
| 07ea5fe84d |
@@ -10,6 +10,10 @@ type Props = {
|
||||
documentId?: string;
|
||||
/** The collection to star */
|
||||
collectionId?: string;
|
||||
/** The parent folder ID */
|
||||
parentId?: string;
|
||||
/** Whether this star is a folder */
|
||||
isFolder?: boolean;
|
||||
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
|
||||
index?: string;
|
||||
/** The request context */
|
||||
@@ -27,6 +31,8 @@ export default async function starCreator({
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
parentId,
|
||||
isFolder = false,
|
||||
ctx,
|
||||
...rest
|
||||
}: Props): Promise<Star> {
|
||||
@@ -54,7 +60,13 @@ export default async function starCreator({
|
||||
}
|
||||
|
||||
const [star] = await Star.findOrCreateWithCtx(ctx, {
|
||||
where: documentId
|
||||
where: isFolder
|
||||
? {
|
||||
userId: user.id,
|
||||
parentId,
|
||||
isFolder: true,
|
||||
}
|
||||
: documentId
|
||||
? {
|
||||
userId: user.id,
|
||||
documentId,
|
||||
@@ -65,6 +77,8 @@ export default async function starCreator({
|
||||
},
|
||||
defaults: {
|
||||
index,
|
||||
parentId,
|
||||
isFolder,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Add parentId column for hierarchical structure
|
||||
await queryInterface.addColumn("stars", "parentId", {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "stars",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
});
|
||||
|
||||
// Add isFolder column to distinguish folders from regular stars
|
||||
await queryInterface.addColumn("stars", "isFolder", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
// Add index for efficient parent-child queries
|
||||
await queryInterface.addIndex("stars", ["parentId"], {
|
||||
name: "stars_parent_id",
|
||||
});
|
||||
|
||||
// Add composite index for user-specific folder queries
|
||||
await queryInterface.addIndex("stars", ["userId", "parentId"], {
|
||||
name: "stars_user_id_parent_id",
|
||||
});
|
||||
|
||||
// Add composite index for filtering folders vs regular stars
|
||||
await queryInterface.addIndex("stars", ["userId", "isFolder"], {
|
||||
name: "stars_user_id_is_folder",
|
||||
});
|
||||
|
||||
// Add check constraint to ensure folders don't have documentId or collectionId
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE stars ADD CONSTRAINT stars_folder_content_check
|
||||
CHECK (
|
||||
("isFolder" = true AND "documentId" IS NULL AND "collectionId" IS NULL) OR
|
||||
("isFolder" = false)
|
||||
)
|
||||
`);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Remove check constraint
|
||||
await queryInterface.removeConstraint(
|
||||
"stars",
|
||||
"stars_folder_content_check"
|
||||
);
|
||||
|
||||
// Remove indexes
|
||||
await queryInterface.removeIndex("stars", "stars_user_id_is_folder");
|
||||
await queryInterface.removeIndex("stars", "stars_user_id_parent_id");
|
||||
await queryInterface.removeIndex("stars", "stars_parent_id");
|
||||
|
||||
// Remove columns
|
||||
await queryInterface.removeColumn("stars", "isFolder");
|
||||
await queryInterface.removeColumn("stars", "parentId");
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
ForeignKey,
|
||||
Table,
|
||||
Length,
|
||||
HasMany,
|
||||
BeforeCreate,
|
||||
BeforeUpdate,
|
||||
} from "sequelize-typescript";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
@@ -26,6 +29,9 @@ class Star extends IdModel<
|
||||
@Column
|
||||
index: string | null;
|
||||
|
||||
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false })
|
||||
isFolder: boolean;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
@@ -48,6 +54,28 @@ class Star extends IdModel<
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string | null;
|
||||
|
||||
@BelongsTo(() => Star, "parentId")
|
||||
parent: Star | null;
|
||||
|
||||
@ForeignKey(() => Star)
|
||||
@Column(DataType.UUID)
|
||||
parentId: string | null;
|
||||
|
||||
@HasMany(() => Star, "parentId")
|
||||
children: Star[];
|
||||
|
||||
@BeforeCreate
|
||||
@BeforeUpdate
|
||||
static async validateFolderNesting(star: Star) {
|
||||
// Prevent folders from being nested inside other folders
|
||||
if (star.isFolder && star.parentId) {
|
||||
const parent = await Star.findByPk(star.parentId);
|
||||
if (parent?.isFolder) {
|
||||
throw new Error("Folders cannot be nested inside other folders");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Star;
|
||||
|
||||
@@ -5,6 +5,8 @@ export default function presentStar(star: Star) {
|
||||
id: star.id,
|
||||
documentId: star.documentId,
|
||||
collectionId: star.collectionId,
|
||||
parentId: star.parentId,
|
||||
isFolder: star.isFolder,
|
||||
index: star.index,
|
||||
createdAt: star.createdAt,
|
||||
updatedAt: star.updatedAt,
|
||||
|
||||
@@ -13,6 +13,8 @@ export const StarsCreateSchema = BaseSchema.extend({
|
||||
})
|
||||
.optional(),
|
||||
collectionId: z.string().uuid().optional(),
|
||||
parentId: z.string().uuid().optional(),
|
||||
isFolder: z.boolean().optional(),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, {
|
||||
@@ -21,16 +23,44 @@ export const StarsCreateSchema = BaseSchema.extend({
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(body) => !(isEmpty(body.documentId) && isEmpty(body.collectionId)),
|
||||
(body) => {
|
||||
// If isFolder is true, documentId and collectionId must be empty
|
||||
if (body.isFolder) {
|
||||
return isEmpty(body.documentId) && isEmpty(body.collectionId);
|
||||
}
|
||||
// If not a folder, one of documentId or collectionId is required
|
||||
return !(isEmpty(body.documentId) && isEmpty(body.collectionId));
|
||||
},
|
||||
{
|
||||
message: "One of documentId or collectionId is required",
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(body) => {
|
||||
// Additional validation: folders cannot have documentId or collectionId
|
||||
if (
|
||||
body.isFolder &&
|
||||
(!isEmpty(body.documentId) || !isEmpty(body.collectionId))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Folders cannot have documentId or collectionId",
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export type StarsCreateReq = z.infer<typeof StarsCreateSchema>;
|
||||
|
||||
export const StarsListSchema = BaseSchema;
|
||||
export const StarsListSchema = BaseSchema.extend({
|
||||
body: z
|
||||
.object({
|
||||
parentId: z.string().uuid().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type StarsListReq = z.infer<typeof StarsListSchema>;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.StarsCreateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { documentId, collectionId, index } = ctx.input.body;
|
||||
const { documentId, collectionId, parentId, isFolder, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
if (documentId) {
|
||||
@@ -44,11 +44,29 @@ router.post(
|
||||
authorize(user, "star", collection);
|
||||
}
|
||||
|
||||
// Validate parent folder exists and belongs to user
|
||||
if (parentId) {
|
||||
const parentStar = await Star.findOne({
|
||||
where: {
|
||||
id: parentId,
|
||||
userId: user.id,
|
||||
isFolder: true,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!parentStar) {
|
||||
ctx.throw(400, "Parent folder not found or is not a folder");
|
||||
}
|
||||
}
|
||||
|
||||
const star = await starCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
parentId,
|
||||
isFolder: isFolder || false,
|
||||
index,
|
||||
});
|
||||
|
||||
@@ -66,12 +84,20 @@ router.post(
|
||||
validate(T.StarsListSchema),
|
||||
async (ctx: APIContext<T.StarsListReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { parentId } = ctx.input.body || {};
|
||||
|
||||
const whereClause: any = {
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
// Filter by parentId if provided
|
||||
if (parentId !== undefined) {
|
||||
whereClause.parentId = parentId;
|
||||
}
|
||||
|
||||
const [stars, collectionIds] = await Promise.all([
|
||||
Star.findAll({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
where: whereClause,
|
||||
order: [
|
||||
Sequelize.literal('"star"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
|
||||
Reference in New Issue
Block a user