Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8bef81d
feat: add AI tools route factory and integrate with API v3
miya Oct 22, 2025
cb3c455
feat: implement create page route for AI tools
miya Oct 22, 2025
8e1d4d0
Merge pull request #10435 from growilabs/feat/172490-implementation-o…
yuki-takei Oct 24, 2025
966746a
Merge branch 'master' into feat/high-level-growi-service-for-mcp-server
miya Oct 30, 2025
dbf7287
Merge branch 'master' into feat/high-level-growi-service-for-mcp-server
miya Nov 4, 2025
43447af
refactor: improve validation error messages in create page handler
miya Nov 4, 2025
c23fc7b
feat: add determinePath function to validate page path inputs
miya Nov 4, 2025
4e1c538
feat: enhance determinePath function to validate and normalize page p…
miya Nov 4, 2025
5387833
feat: enhance determinePath to include detailed path logging and vali…
miya Nov 6, 2025
016b107
feat: update create page handler to enforce body as required and impr…
miya Nov 6, 2025
2f5fa16
feat: remove optional pageTags validation from create page handler
miya Nov 6, 2025
a23c4f5
feat: add grantUserGroupIds validation to create page handler
miya Nov 6, 2025
62ae861
feat: reorder pathHintKeywords validation in create page handler
miya Nov 6, 2025
2ad49c2
fix grant type
miya Nov 6, 2025
0a46c6e
0 -> 1
miya Nov 6, 2025
e7bfdad
Update apps/app/src/features/ai-tools/server/routes/create-page.ts
miya Nov 6, 2025
aab0e53
fix: improve error message for pathHintKeywords and reorder propertie…
miya Nov 6, 2025
a176df8
small fix
miya Nov 6, 2025
83b8e1c
Add TODO
miya Nov 6, 2025
5f27624
feat: implement create page helpers with body and tags determination,…
miya Nov 7, 2025
b390c17
Use create-page-helpers
miya Nov 7, 2025
ffceb40
imprv error handling
miya Nov 7, 2025
679cd3e
imprv error handling
miya Nov 7, 2025
cea3923
refactor: simplify path determination logic and extract to create-pag…
miya Nov 10, 2025
5a115df
create-page-helper -> create-page
miya Nov 10, 2025
66c7f84
refactor: simplify path generation in generateTodaysMemoPath function
miya Nov 10, 2025
ba96d51
Merge pull request #10465 from growilabs/feat/173264-implementation-o…
yuki-takei Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = {
'src/features/growi-plugin/**',
'src/features/opentelemetry/**',
'src/features/openai/**',
'src/features/ai-tools/**',
'src/features/rate-limiter/**',
'src/stores-universal/**',
'src/interfaces/**',
Expand Down
208 changes: 208 additions & 0 deletions apps/app/src/features/ai-tools/server/routes/create-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {
GroupType,
type IUser,
type IUserHasId,
SCOPE,
} from '@growi/core/dist/interfaces';
import { isUserPage } from '@growi/core/dist/utils/page-path-utils';
import type { Request, RequestHandler } from 'express';
import type { ValidationChain } from 'express-validator';
import { body } from 'express-validator';
import mongoose, { type HydratedDocument } from 'mongoose';

import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
import type { IOptionsForCreate } from '~/interfaces/page';
import type Crowi from '~/server/crowi';
import { accessTokenParser } from '~/server/middlewares/access-token-parser';
import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
import type { PageDocument } from '~/server/models/page';
import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
import {
determineBodyAndTags,
postAction,
saveTags,
} from '~/server/routes/apiv3/page/create-page-helpers';
import loggerFactory from '~/utils/logger';

import { determinePath } from '../services/create-page';

const logger = loggerFactory('growi:routes:apiv3:ai-tools:create-page');

type ReqBody = IApiv3PageCreateParams & {
todaysMemoTitle?: string;
pathHintKeywords?: string[];
};

type CreatePageReq = Request<undefined, ApiV3Response, ReqBody> & {
user: IUserHasId;
};

type CreatePageFactory = (crowi: Crowi) => RequestHandler[];

export const createPageHandlersFactory: CreatePageFactory = (crowi) => {
const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>(
'User',
);

const loginRequiredStrictly = require('~/server/middlewares/login-required')(
crowi,
);

const validator: ValidationChain[] = [
body('path').optional().isString().withMessage('"path" must be string'),

body('todaysMemoTitle')
.optional()
.isString()
.withMessage('"todaysMemoTitle" must be string'),

body('pathHintKeywords')
.optional()
.isArray()
.withMessage('"pathHintKeywords" must be array'),

body('body').isString().withMessage('"body" must be string'),

body('grant')
.optional()
.isInt({ min: 1, max: 5 })
.withMessage('"grant" must be integer from 1 to 5'),

body('grantUserGroupIds')
.optional()
.isArray()
.withMessage('"grantUserGroupIds" must be array'),

body('grantUserGroupIds.*.type')
.optional()
.isIn([GroupType.userGroup, GroupType.externalUserGroup])
.withMessage(
'"grantUserGroupIds.*.type" must be either "userGroup" or "externalUserGroup"',
),

body('grantUserGroupIds.*.item')
.optional()
.isMongoId()
.withMessage(
'"grantUserGroupIds.*.item" must be a valid MongoDB ObjectId',
),

body('onlyInheritUserRelatedGrantedGroups')
.optional()
.isBoolean()
.withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),

body('overwriteScopesOfDescendants')
.optional()
.isBoolean()
.withMessage('overwriteScopesOfDescendants must be boolean'),

body('pageTags').optional().isArray().withMessage('pageTags must be array'),

body('isSlackEnabled')
.optional()
.isBoolean()
.withMessage('isSlackEnabled must be boolean'),

body('slackChannels')
.optional()
.isString()
.withMessage('slackChannels must be string'),

body('wip').optional().isBoolean().withMessage('wip must be boolean'),
];

const addActivity = generateAddActivityMiddleware();

return [
accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), // TODO: https://redmine.weseek.co.jp/issues/172491
loginRequiredStrictly,
excludeReadOnlyUser,
addActivity,
validator,
apiV3FormValidator,
async (req: CreatePageReq, res: ApiV3Response) => {
const {
path,
todaysMemoTitle,
pathHintKeywords,
body,
grant,
grantUserGroupIds,
pageTags,
onlyInheritUserRelatedGrantedGroups,
overwriteScopesOfDescendants,
wip,
} = req.body;

if (
path == null &&
todaysMemoTitle == null &&
(pathHintKeywords == null || pathHintKeywords.length === 0)
) {
return res.apiv3Err(
'Either "path", "todaysMemoTitle" or "pathHintKeywords" is required',
400,
);
}

let pathToCreate: string;
try {
pathToCreate = await determinePath(
req.user,
path,
todaysMemoTitle,
pathHintKeywords,
);
} catch (err) {
logger.error(err);
return res.apiv3Err('Could not determine page path', 400);
}

if (isUserPage(pathToCreate)) {
const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
if (!isExistUser) {
return res.apiv3Err(
"Unable to create a page under a non-existent user's user page",
);
}
}

const { body: determinedBody, tags: determinedTags } =
await determineBodyAndTags(pathToCreate, body, pageTags);

let createdPage: HydratedDocument<PageDocument>;
try {
const options: IOptionsForCreate = {
onlyInheritUserRelatedGrantedGroups,
overwriteScopesOfDescendants,
wip,
};

if (grant != null) {
options.grant = grant;
options.grantUserGroupIds = grantUserGroupIds;
}

createdPage = await crowi.pageService.create(
pathToCreate,
determinedBody,
req.user,
options,
);
} catch (err) {
logger.error('Error occurred while creating a page.', err);
return res.apiv3Err(err);
}

await saveTags({ createdPage, pageTags: determinedTags }, crowi);

// TODO: https://redmine.weseek.co.jp/issues/173816
res.apiv3({}, 201);

postAction(req, res, createdPage, crowi);
},
];
};
13 changes: 13 additions & 0 deletions apps/app/src/features/ai-tools/server/routes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import express from 'express';

import type Crowi from '~/server/crowi';

import { createPageHandlersFactory } from './create-page';

const router = express.Router();

export const factory = (crowi: Crowi): express.Router => {
// TODO: https://redmine.weseek.co.jp/issues/173815
router.post('/page', createPageHandlersFactory(crowi));
return router;
};
64 changes: 64 additions & 0 deletions apps/app/src/features/ai-tools/server/services/create-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { IUserHasId } from '@growi/core/dist/interfaces';
import {
isCreatablePage,
userHomepagePath,
} from '@growi/core/dist/utils/page-path-utils';
import { normalizePath } from '@growi/core/dist/utils/path-utils';
import { format } from 'date-fns/format';

import { getTranslation } from '~/server/service/i18next';

const normalizeAndValidatePath = (path: string): string => {
const normalizedPath = normalizePath(path);
if (!isCreatablePage(normalizedPath)) {
throw new Error('The specified path is not creatable page path');
}
return normalizedPath;
};

const generateTodaysMemoPath = async (
user: IUserHasId,
todaysMemoTitle: string,
): Promise<string> => {
const { t } = await getTranslation({ lang: user.lang, ns: 'commons' });

const userHomepagePathName = userHomepagePath(user);
const memoPathName = t('create_page_dropdown.todays.memo');
const datePathName = format(new Date(), 'yyyy/MM/dd');
const title = todaysMemoTitle;

const path = `${userHomepagePathName}/${memoPathName}/${datePathName}/${title}`;
const normalizedPath = normalizeAndValidatePath(path);
return normalizedPath;
};

const generatePathFromKeywords = (
user: IUserHasId,
pathHintKeywords: string[],
): Promise<string> => {
// TODO: https://redmine.weseek.co.jp/issues/173810
throw new Error(
'Path determination based on keywords is not yet implemented',
);
};

export const determinePath = async (
user: IUserHasId,
path?: string,
todaysMemoTitle?: string,
pathHintKeywords?: string[],
): Promise<string> => {
if (path != null) {
return normalizeAndValidatePath(path);
}

if (todaysMemoTitle != null) {
return generateTodaysMemoPath(user, todaysMemoTitle);
}

if (pathHintKeywords != null && pathHintKeywords.length > 0) {
return generatePathFromKeywords(user, pathHintKeywords);
}

throw new Error('Cannot determine page path');
};
3 changes: 3 additions & 0 deletions apps/app/src/server/routes/apiv3/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { factory as aiToolsFactory } from '~/features/ai-tools/server/routes/';
import growiPlugin from '~/features/growi-plugin/server/routes/apiv3/admin';
import { factory as openaiRouteFactory } from '~/features/openai/server/routes';
import { allreadyInstalledMiddleware } from '~/server/middlewares/application-not-installed';
Expand Down Expand Up @@ -128,6 +129,8 @@ module.exports = (crowi, app) => {

router.use('/openai', openaiRouteFactory(crowi));

router.use('/ai-tools', aiToolsFactory(crowi));

router.use('/user', userRouteFactory(crowi));

return [router, routerForAdmin, routerForAuth];
Expand Down
Loading
Loading