Просмотр исходного кода

Merge pull request #9008 from weseek/fix/revision-pageid-schema-2

fix: Revision pageId schema type
Yuki Takei 1 год назад
Родитель
Сommit
6cc0b67317
37 измененных файлов с 307 добавлено и 162 удалено
  1. 6 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  2. 5 6
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  3. 4 3
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  4. 6 3
      apps/app/src/client/components/PageComment.tsx
  5. 2 2
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  6. 1 1
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  7. 6 8
      apps/app/src/server/events/user.ts
  8. 2 2
      apps/app/src/server/interfaces/mongoose-utils.ts
  9. 2 2
      apps/app/src/server/models/attachment.ts
  10. 3 3
      apps/app/src/server/models/external-account.ts
  11. 6 6
      apps/app/src/server/models/named-query.ts
  12. 28 28
      apps/app/src/server/models/page.ts
  13. 1 3
      apps/app/src/server/models/password-reset-order.ts
  14. 15 6
      apps/app/src/server/models/revision.ts
  15. 1 2
      apps/app/src/server/models/share-link.ts
  16. 3 4
      apps/app/src/server/models/update-post.ts
  17. 4 5
      apps/app/src/server/models/user-group-relation.ts
  18. 1 3
      apps/app/src/server/models/user-group.ts
  19. 1 3
      apps/app/src/server/models/user.js
  20. 6 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  21. 3 2
      apps/app/src/server/routes/apiv3/page/index.ts
  22. 4 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  23. 2 2
      apps/app/src/server/routes/attachment/get.ts
  24. 11 4
      apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts
  25. 2 2
      apps/app/src/server/service/normalize-data/index.ts
  26. 3 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  27. 35 24
      apps/app/src/server/service/page/index.ts
  28. 3 3
      apps/app/src/server/service/page/page-service.ts
  29. 2 2
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  30. 3 3
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  31. 1 1
      apps/app/src/server/util/granted-group.ts
  32. 1 1
      apps/app/test/integration/service/page.test.js
  33. 8 8
      apps/app/test/integration/service/v5.non-public-page.test.ts
  34. 2 1
      packages/core/package.json
  35. 110 0
      packages/core/src/interfaces/common.spec.ts
  36. 13 5
      packages/core/src/interfaces/common.ts
  37. 1 1
      packages/core/src/interfaces/user.ts

+ 6 - 6
apps/app/src/client/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -2,7 +2,7 @@ import type { FC } from 'react';
 import React, { useCallback, useState, useMemo } from 'react';
 
 import {
-  getIdForRef, isPopulated, type IGrantedGroup, type IUserGroupHasId,
+  getIdStringForRef, isPopulated, type IGrantedGroup, type IUserGroupHasId,
 } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
@@ -90,8 +90,8 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, [setActionName]);
 
   const handleGroupChange = useCallback((e) => {
-    const transferToUserGroupId = e.target.value;
-    const selectedGroup = userGroups.find(group => getIdForRef(group.item) === transferToUserGroupId) ?? null;
+    const transferToUserGroupId: string = e.target.value;
+    const selectedGroup = userGroups.find(group => getIdStringForRef(group.item) === transferToUserGroupId) ?? null;
     setTransferToUserGroup(selectedGroup);
   }, [userGroups]);
 
@@ -136,11 +136,11 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     }
 
     const groups = userGroups.filter((group) => {
-      return getIdForRef(group.item) !== deleteUserGroup._id;
+      return getIdStringForRef(group.item) !== deleteUserGroup._id;
     });
 
     const options = groups.map((group) => {
-      const groupId = getIdForRef(group.item);
+      const groupId = getIdStringForRef(group.item);
       const groupName = isPopulated(group.item) ? group.item.name : null;
       return { id: groupId, name: groupName };
     }).filter(obj => obj.name != null)
@@ -153,7 +153,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       <select
         name="transferToUserGroup"
         className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
-        value={transferToUserGroup != null ? getIdForRef(transferToUserGroup.item) : ''}
+        value={transferToUserGroup != null ? getIdStringForRef(transferToUserGroup.item) : ''}
         onChange={handleGroupChange}
       >
         <option value="" disabled>{defaultOptionText}</option>

+ 5 - 6
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  GroupType, getIdForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
+  GroupType, getIdStringForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
 } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
@@ -130,8 +130,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
-    const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
+  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: IUserGroupHasId, forceUpdateParents: boolean) => {
     if (isExternalGroup) {
       await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
         description: update.description,
@@ -141,7 +140,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
         name: update.name,
         description: update.description,
-        parentId: parentId ?? null,
+        parentId: update.parent != null ? getIdStringForRef(update.parent) : null,
         forceUpdateParents,
       });
     }
@@ -154,7 +153,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
   const onSubmitUpdateGroup = useCallback(
-    async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
+    async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId, forceUpdateParents: boolean): Promise<void> => {
       try {
         await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
         toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
@@ -303,7 +302,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup | null) => {
     const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
-    const transferToUserGroupId = transferToUserGroup != null ? getIdForRef(transferToUserGroup.item) : null;
+    const transferToUserGroupId = transferToUserGroup != null ? getIdStringForRef(transferToUserGroup.item) : null;
     const transferToUserGroupType = transferToUserGroup != null ? transferToUserGroup.type : null;
     try {
       const res = await apiv3Delete(url, {

+ 4 - 3
apps/app/src/client/components/Me/ProfileImageSettings.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useState } from 'react';
 
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
@@ -21,10 +22,10 @@ const ProfileImageSettings = (): JSX.Element => {
 
   const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
   const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
-    if (typeof currentUser?.imageAttachment === 'string') {
-      return currentUser?.image;
+    if (currentUser?.imageAttachment != null && isPopulated(currentUser.imageAttachment)) {
+      return currentUser.imageAttachment.filePathProxied ?? currentUser.image;
     }
-    return currentUser?.imageAttachment?.filePathProxied ?? currentUser?.image;
+    return currentUser?.image;
   });
 
   const [showImageCropModal, setShowImageCropModal] = useState(false);

+ 6 - 3
apps/app/src/client/components/PageComment.tsx

@@ -3,7 +3,10 @@ import React, {
   useState, useMemo, memo, useCallback,
 } from 'react';
 
-import { isPopulated, getIdForRef, type IRevisionHasId } from '@growi/core';
+import type { IRevision, Ref } from '@growi/core';
+import {
+  isPopulated, getIdStringForRef,
+} from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
@@ -30,7 +33,7 @@ type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
   pagePath: string,
-  revision: string | IRevisionHasId,
+  revision: Ref<IRevision>,
   currentUser: any,
   isReadOnly: boolean,
 }
@@ -121,7 +124,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
     return <></>;
   }
 
-  const revisionId = getIdForRef(revision);
+  const revisionId = getIdStringForRef(revision);
   const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
 
   const commentElement = (comment: ICommentHasId) => (

+ 2 - 2
apps/app/src/client/components/SearchPage/SearchResultContent.tsx

@@ -3,7 +3,7 @@ import React, {
   useCallback, useEffect, useRef,
 } from 'react';
 
-import { getIdForRef } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -184,7 +184,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       return <></>;
     }
 
-    const revisionId = page.revision != null ? getIdForRef(page.revision) : null;
+    const revisionId = page.revision != null ? getIdStringForRef(page.revision) : null;
     const additionalMenuItemRenderer = revisionId != null
       ? props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />
       : undefined;

+ 1 - 1
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -26,7 +26,7 @@ export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupR
 
   findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>
 
-  findAllUserGroupIdsRelatedToUser: (user) => Promise<string[]>
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>
 }
 
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({

+ 6 - 8
apps/app/src/server/events/user.ts

@@ -1,10 +1,11 @@
 import EventEmitter from 'events';
 
-import type { IPage, IUserHasId } from '@growi/core';
+import { getIdStringForRef, type IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
 import { deleteCompletelyUserHomeBySystem } from '../service/page/delete-completely-user-home-by-system';
@@ -22,16 +23,13 @@ class UserEvent extends EventEmitter {
   }
 
   async onActivated(user: IUserHasId): Promise<void> {
-    const Page = mongoose.model<IPage, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
     try {
-      let page = await Page.findByPath(userHomepagePath, true);
+      let page: HydratedDocument<PageDocument> | null = await Page.findByPath(userHomepagePath, true);
 
-      // TODO: Make it more type safe
-      // Since the type of page.creator is 'any', we resort to the following comparison,
-      // checking if page.creator.toString() is not equal to user._id.toString(). Our code covers null, string, or object types.
-      if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
+      if (page != null && page.creator != null && getIdStringForRef(page.creator) !== user._id.toString()) {
         await deleteCompletelyUserHomeBySystem(userHomepagePath, this.crowi.pageService);
         page = null;
       }

+ 2 - 2
apps/app/src/server/interfaces/mongoose-utils.ts

@@ -1,3 +1,3 @@
-import mongoose from 'mongoose';
+import type { Types } from 'mongoose';
 
-export type ObjectIdLike = mongoose.Types.ObjectId | string;
+export type ObjectIdLike = Types.ObjectId | string;

+ 2 - 2
apps/app/src/server/models/attachment.ts

@@ -36,8 +36,8 @@ export interface IAttachmentModel extends Model<IAttachmentDocument> {
 }
 
 const attachmentSchema = new Schema({
-  page: { type: Types.ObjectId, ref: 'Page', index: true },
-  creator: { type: Types.ObjectId, ref: 'User', index: true },
+  page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
   fileName: { type: String, required: true, unique: true },
   fileFormat: { type: String, required: true },

+ 3 - 3
apps/app/src/server/models/external-account.ts

@@ -1,7 +1,8 @@
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 import type { IExternalAccount, IExternalAccountHasId, IUserHasId } from '@growi/core';
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 
@@ -12,7 +13,6 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
 
 export interface ExternalAccountDocument extends IExternalAccount, Document {}
 
@@ -23,7 +23,7 @@ export interface ExternalAccountModel extends Model<ExternalAccountDocument> {
 const schema = new Schema<ExternalAccountDocument, ExternalAccountModel>({
   providerType: { type: String, required: true },
   accountId: { type: String, required: true },
-  user: { type: ObjectId, ref: 'User', required: true },
+  user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
 }, {
   timestamps: { createdAt: true, updatedAt: false },
 });

+ 6 - 6
apps/app/src/server/models/named-query.ts

@@ -1,10 +1,12 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import mongoose, {
-  Schema, Model, Document,
+import type { Model, Document } from 'mongoose';
+import {
+  Schema,
 } from 'mongoose';
 
-import { INamedQuery, SearchDelegatorName } from '~/interfaces/named-query';
+import type { INamedQuery } from '~/interfaces/named-query';
+import { SearchDelegatorName } from '~/interfaces/named-query';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -17,14 +19,12 @@ export interface NamedQueryDocument extends INamedQuery, Document {}
 
 export type NamedQueryModel = Model<NamedQueryDocument>
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
 const schema = new Schema<NamedQueryDocument, NamedQueryModel>({
   name: { type: String, required: true, unique: true },
   aliasOf: { type: String },
   delegatorName: { type: String, enum: SearchDelegatorName },
   creator: {
-    type: ObjectId, ref: 'User', index: true, default: null,
+    type: Schema.Types.ObjectId, ref: 'User', index: true, default: null,
   },
 });
 

+ 28 - 28
apps/app/src/server/models/page.ts

@@ -11,10 +11,12 @@ import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import type { Model, Document, AnyObject } from 'mongoose';
-import mongoose, {
-  Schema,
+import type {
+  Model, Document, AnyObject,
+  HydratedDocument,
+  Types,
 } from 'mongoose';
+import mongoose, { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
@@ -44,7 +46,7 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-export interface PageDocument extends IPage, Document {
+export interface PageDocument extends IPage, Document<Types.ObjectId> {
   [x:string]: any // for obsolete methods
   getLatestRevisionBodyLength(): Promise<number | null | undefined>
   calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>
@@ -63,25 +65,26 @@ type PaginatedPages = {
   offset: number
 }
 
-export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<HydratedDocument<PageDocument>>
 
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
-  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<PageDocument & HasObjectId>
+  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
   findByIdsAndViewer(
     pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
-  ): Promise<(PageDocument & HasObjectId)[]>
-  findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
+  ): Promise<HydratedDocument<PageDocument>[]>
+  findByPath(path: string, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument>[]>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
+  findParentByPath(path: string | null): Promise<HydratedDocument<PageDocument> | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
-    user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+    user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
-  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | null>
-  findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
+  findNonEmptyClosestAncestor(path: string): Promise<HydratedDocument<PageDocument> | null>
+  findNotEmptyParentByPathRecursively(path: string): Promise<HydratedDocument<PageDocument> | null>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
     templateBody?: string,
@@ -101,22 +104,21 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
 }
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<PageDocument, PageModel>({
   parent: {
-    type: ObjectId, ref: 'Page', index: true, default: null,
+    type: Schema.Types.ObjectId, ref: 'Page', index: true, default: null,
   },
   descendantCount: { type: Number, default: 0 },
   isEmpty: { type: Boolean, default: false },
   path: {
     type: String, required: true, index: true,
   },
-  revision: { type: ObjectId, ref: 'Revision' },
+  revision: { type: Schema.Types.ObjectId, ref: 'Revision' },
   latestRevisionBodyLength: { type: Number },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
-  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
   grantedGroups: {
     type: [{
       type: {
@@ -126,7 +128,7 @@ const schema = new Schema<PageDocument, PageModel>({
         default: 'UserGroup',
       },
       item: {
-        type: ObjectId,
+        type: Schema.Types.ObjectId,
         refPath: 'grantedGroups.type',
         required: true,
         index: true,
@@ -140,16 +142,16 @@ const schema = new Schema<PageDocument, PageModel>({
     default: [],
     required: true,
   },
-  creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User' },
-  liker: [{ type: ObjectId, ref: 'User' }],
-  seenUsers: [{ type: ObjectId, ref: 'User' }],
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  lastUpdateUser: { type: Schema.Types.ObjectId, ref: 'User' },
+  liker: [{ type: Schema.Types.ObjectId, ref: 'User' }],
+  seenUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   expandContentWidth: { type: Boolean },
   wip: { type: Boolean },
   ttlTimestamp: { type: Date },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
-  deleteUser: { type: ObjectId, ref: 'User' },
+  deleteUser: { type: Schema.Types.ObjectId, ref: 'User' },
   deletedAt: { type: Date },
 }, {
   timestamps: { createdAt: true, updatedAt: false },
@@ -423,7 +425,7 @@ export class PageQueryBuilder {
   }
 
   addConditionToFilteringByViewer(
-      user, userGroups: string[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+      user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
   ): PageQueryBuilder {
     const condition = generateGrantCondition(user, userGroups, includeAnyoneWithTheLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
@@ -757,10 +759,8 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], aggrPip
 
 /**
  * Find a parent page by path
- * @param {string} path
- * @returns {Promise<PageDocument | null>}
  */
-schema.statics.findParentByPath = async function(path: string): Promise<PageDocument | null> {
+schema.statics.findParentByPath = async function(path: string): Promise<HydratedDocument<PageDocument> | null> {
   const parentPath = nodePath.dirname(path);
 
   const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
@@ -968,7 +968,7 @@ schema.statics.findParent = async function(pageId): Promise<PageDocument | null>
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 export function generateGrantCondition(
-    user, userGroups: string[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+    user, userGroups: ObjectIdLike[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
 ): { $or: any[] } {
   const grantConditions: AnyObject[] = [
     { grant: null },

+ 1 - 3
apps/app/src/server/models/password-reset-order.ts

@@ -10,8 +10,6 @@ import uniqueValidator from 'mongoose-unique-validator';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
 export interface IPasswordResetOrder {
   token: string,
   email: string,
@@ -39,7 +37,7 @@ const expiredAt = (): Date => {
 const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
-  relatedUser: { type: ObjectId, ref: 'User' },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User' },
   isRevoked: { type: Boolean, default: false, required: true },
   expiredAt: { type: Date, default: expiredAt, required: true },
 }, {

+ 15 - 6
apps/app/src/server/models/revision.ts

@@ -4,8 +4,9 @@ import type {
   Origin,
 } from '@growi/core';
 import { allOrigin } from '@growi/core';
+import type { Types } from 'mongoose';
 import {
-  Schema, Types, type Document, type Model,
+  Schema, type Document, type Model,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
@@ -17,10 +18,11 @@ import type { PageDocument } from './page';
 
 const logger = loggerFactory('growi:models:revision');
 
+
 export interface IRevisionDocument extends IRevision, Document {
 }
 
-type UpdateRevisionListByPageId = (pageId: string, updateData: Partial<IRevision>) => Promise<void>;
+type UpdateRevisionListByPageId = (pageId: Types.ObjectId, updateData: Partial<IRevision>) => Promise<void>;
 type PrepareRevision = (
   pageData: PageDocument, body: string, previousBody: string | null, user: HasObjectId, origin?: Origin, options?: { format: string }
 ) => IRevisionDocument;
@@ -37,7 +39,7 @@ const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
   // The type of pageId is always converted to String at server startup
   // Refer to this method (/src/server/service/normalize-data/convert-revision-page-id-to-string.ts) to change the pageId type
   pageId: {
-    type: String, required: true, index: true,
+    type: Schema.Types.ObjectId, ref: 'Page', required: true, index: true,
   },
   body: {
     type: String,
@@ -49,7 +51,7 @@ const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
     },
   },
   format: { type: String, default: 'markdown' },
-  author: { type: Types.ObjectId, ref: 'User' },
+  author: { type: Schema.Types.ObjectId, ref: 'User' },
   hasDiffToPrev: { type: Boolean },
   origin: { type: String, enum: allOrigin },
 }, {
@@ -58,13 +60,20 @@ const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
 revisionSchema.plugin(mongoosePaginate);
 
 const updateRevisionListByPageId: UpdateRevisionListByPageId = async function(this: IRevisionModel, pageId, updateData) {
+  // Check pageId for safety
+  if (pageId == null) {
+    throw new Error('Error: pageId is required');
+  }
   await this.updateMany({ pageId }, { $set: updateData });
 };
 revisionSchema.statics.updateRevisionListByPageId = updateRevisionListByPageId;
 
 const prepareRevision: PrepareRevision = function(this: IRevisionModel, pageData, body, previousBody, user, origin, options = { format: 'markdown' }) {
-  if (!user._id) {
-    throw new Error('Error: user should have _id');
+  if (user._id == null) {
+    throw new Error('user should have _id');
+  }
+  if (pageData._id == null) {
+    throw new Error('pageData should have _id');
   }
 
   const newRevision = new this();

+ 1 - 2
apps/app/src/server/models/share-link.ts

@@ -20,10 +20,9 @@ export type ShareLinkModel = Model<ShareLinkDocument>;
 /*
  * define schema
  */
-const ObjectId = mongoose.Schema.Types.ObjectId;
 const schema = new Schema<ShareLinkDocument, ShareLinkModel>({
   relatedPage: {
-    type: ObjectId,
+    type: Schema.Types.ObjectId,
     ref: 'Page',
     required: true,
     index: true,

+ 3 - 4
apps/app/src/server/models/update-post.ts

@@ -1,8 +1,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import {
-  Types, Schema, Model, Document,
-} from 'mongoose';
+import type { Types, Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
@@ -12,7 +11,7 @@ export interface IUpdatePost {
   patternPrefix2: string
   channel: string
   provider: string
-  creator: Schema.Types.ObjectId
+  creator: Types.ObjectId
   createdAt: Date
 }
 

+ 4 - 5
apps/app/src/server/models/user-group-relation.ts

@@ -13,7 +13,6 @@ const debug = require('debug')('growi:models:userGroupRelation');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = Schema.Types.ObjectId;
 
 export interface UserGroupRelationDocument extends IUserGroupRelation, Document {}
 
@@ -32,15 +31,15 @@ export interface UserGroupRelationModel extends Model<UserGroupRelationDocument>
 
   findAllGroupsForUser: (user) => Promise<UserGroupDocument[]>
 
-  findAllUserGroupIdsRelatedToUser: (user) => Promise<string[]>
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>
 }
 
 /*
  * define schema
  */
 const schema = new Schema<UserGroupRelationDocument, UserGroupRelationModel>({
-  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
-  relatedUser: { type: ObjectId, ref: 'User', required: true },
+  relatedGroup: { type: Schema.Types.ObjectId, ref: 'UserGroup', required: true },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
 }, {
   timestamps: { createdAt: true, updatedAt: false },
 });
@@ -143,7 +142,7 @@ schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDoc
  * @param {User} user
  * @returns {Promise<ObjectId[]>}
  */
-schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<string[]> {
+schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<ObjectIdLike[]> {
   const relations = await this.find({ relatedUser: user._id })
     .select('relatedGroup')
     .exec();

+ 1 - 3
apps/app/src/server/models/user-group.ts

@@ -19,11 +19,9 @@ export interface UserGroupModel extends Model<UserGroupDocument> {
 /*
  * define schema
  */
-const ObjectId = Schema.Types.ObjectId;
-
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
   name: { type: String, required: true, unique: true },
-  parent: { type: ObjectId, ref: 'UserGroup', index: true },
+  parent: { type: Schema.Types.ObjectId, ref: 'UserGroup', index: true },
   description: { type: String, default: '' },
 }, {
   timestamps: true,

+ 1 - 3
apps/app/src/server/models/user.js

@@ -15,8 +15,6 @@ const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
 const { omitInsecureAttributes } = require('./serializers/user-serializer');
 
 const logger = loggerFactory('growi:models:user');
@@ -44,7 +42,7 @@ module.exports = function(crowi) {
   const userSchema = new mongoose.Schema({
     userId: String,
     image: String,
-    imageAttachment: { type: ObjectId, ref: 'Attachment' },
+    imageAttachment: { type: mongoose.Schema.Types.ObjectId, ref: 'Attachment' },
     imageUrlCached: String,
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },

+ 6 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -6,6 +6,7 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import express from 'express';
 import { query, oneOf } from 'express-validator';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
 
@@ -14,7 +15,7 @@ import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import type { PageModel } from '../../models/page';
+import type { PageDocument, PageModel } from '../../models/page';
 
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -122,7 +123,7 @@ const routerFactory = (crowi: Crowi): Router => {
     const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
     const attachShortBody: boolean = attachShortBodyParam === 'true';
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageService = crowi.pageService;
@@ -171,11 +172,11 @@ const routerFactory = (crowi: Crowi): Router => {
           : {
             ...basicPageInfo,
             isAbleToDeleteCompletely: canDeleteCompletely,
-            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
-            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
+            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] : undefined,
+            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
           } as IPageInfoForListing;
 
-        idToPageInfoMap[page._id] = pageInfo;
+        idToPageInfoMap[page._id.toString()] = pageInfo;
       }
 
       return res.apiv3(idToPageInfoMap);

+ 3 - 2
apps/app/src/server/routes/apiv3/page/index.ts

@@ -8,6 +8,7 @@ import {
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
@@ -747,9 +748,9 @@ module.exports = (crowi) => {
     let revision;
     let pagePath;
 
-    const Page = mongoose.model<PageDocument, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
-    let page: PageDocument;
+    let page: HydratedDocument<PageDocument> | null;
 
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);

+ 4 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -132,6 +132,10 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
+      // check page existence (for type safety)
+      if (currentPage == null) {
+        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+      }
 
       if (currentPage != null) {
         // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'

+ 2 - 2
apps/app/src/server/routes/attachment/get.ts

@@ -1,5 +1,5 @@
 import {
-  getIdForRef, type IPage, type IUser,
+  getIdStringForRef, type IPage, type IUser,
 } from '@growi/core';
 import express from 'express';
 import type {
@@ -59,7 +59,7 @@ export const retrieveAttachmentFromIdParam = async(
   // check viewer has permission
   if (user != null && attachment.page != null) {
     const Page = mongoose.model<IPage, PageModel>('Page');
-    const isAccessible = await Page.isAccessiblePageByViewer(getIdForRef(attachment.page), user);
+    const isAccessible = await Page.isAccessiblePageByViewer(getIdStringForRef(attachment.page), user);
     if (!isAccessible) {
       res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
       return;

+ 11 - 4
apps/app/src/server/service/normalize-data/convert-revision-page-id-to-string.ts → apps/app/src/server/service/normalize-data/convert-revision-page-id-to-objectid.ts

@@ -1,22 +1,29 @@
 // see: https://redmine.weseek.co.jp/issues/150649
 
 import { type IRevisionHasId } from '@growi/core';
+import type { FilterQuery, UpdateQuery } from 'mongoose';
 import mongoose from 'mongoose';
 
+import type { IRevisionDocument } from '~/server/models/revision';
 import { type IRevisionModel } from '~/server/models/revision';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:NormalizeData:convert-revision-page-id-to-string');
 
-export const convertRevisionPageIdToString = async(): Promise<void> => {
+export const convertRevisionPageIdToObjectId = async(): Promise<void> => {
   const Revision = mongoose.model<IRevisionHasId, IRevisionModel>('Revision');
 
-  const filter = { pageId: { $type: 'objectId' } };
-  const update = [
+  const filter: FilterQuery<IRevisionDocument> = { pageId: { $type: 'string' } };
+
+  const update: UpdateQuery<IRevisionDocument> = [
     {
       $set: {
         pageId: {
-          $toString: '$pageId',
+          $convert: {
+            input: '$pageId',
+            to: 'objectId',
+            onError: '$pageId',
+          },
         },
       },
     },

+ 2 - 2
apps/app/src/server/service/normalize-data/index.ts

@@ -1,13 +1,13 @@
 import loggerFactory from '~/utils/logger';
 
-import { convertRevisionPageIdToString } from './convert-revision-page-id-to-string';
+import { convertRevisionPageIdToObjectId } from './convert-revision-page-id-to-objectid';
 import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
 
 const logger = loggerFactory('growi:service:NormalizeData');
 
 export const normalizeData = async(): Promise<void> => {
   await renameDuplicateRootPages();
-  await convertRevisionPageIdToString();
+  await convertRevisionPageIdToObjectId();
 
   logger.info('normalizeData has been executed');
   return;

+ 3 - 2
apps/app/src/server/service/page/delete-completely-user-home-by-system.ts

@@ -3,10 +3,11 @@ import { Writable } from 'stream';
 import { getIdForRef } from '@growi/core';
 import type { IPage, Ref } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
+import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 
@@ -38,7 +39,7 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
     throw new Error(msg);
   }
 
-  const Page = mongoose.model<IPage, PageModel>('Page');
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
   const userHomepage = await Page.findByPath(userHomepagePath, true);
 
   if (userHomepage == null) {

+ 35 - 24
apps/app/src/server/service/page/index.ts

@@ -4,16 +4,18 @@ import { Readable, Writable } from 'stream';
 
 import type {
   Ref, HasObjectId, IUserHasId, IUser,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IGrantedGroup, IRevisionHasId,
+  IDataWithMeta,
 } from '@growi/core';
 import {
   PageGrant, PageStatus, YDocStatus, getIdForRef,
+  getIdStringForRef,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import type { ObjectId, Cursor } from 'mongoose';
+import type { Cursor, HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
@@ -53,6 +55,7 @@ import { PathAlreadyExistsError } from '../../models/errors';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageRedirect from '../../models/page-redirect';
+import type { IRevisionDocument } from '../../models/revision';
 import { Revision } from '../../models/revision';
 import { serializePageSecurely } from '../../models/serializers/page-serializer';
 import ShareLink from '../../models/share-link';
@@ -405,11 +408,11 @@ class PageService implements IPageService {
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   async findPageAndMetaDataByViewer(
       pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false,
-  ): Promise<IPageWithMeta<IPageInfoAll>|null> {
+  ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null> {
 
-    const Page = this.crowi.model('Page') as PageModel;
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
-    let page: PageDocument & HasObjectId | null;
+    let page: HydratedDocument<PageDocument> | null;
     if (pageId != null) { // prioritized
       page = await Page.findByIdAndViewer(pageId, user, null, includeEmpty);
     }
@@ -832,7 +835,7 @@ class PageService implements IPageService {
   }
 
   private async renamePageV4(page, newPagePath, user, options) {
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     const {
       isRecursively = false,
       createRedirectPage = false,
@@ -858,6 +861,9 @@ class PageService implements IPageService {
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
 
     // update Rivisions
+    if (renamedPage == null) {
+      throw new Error('Failed to rename page');
+    }
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
     if (createRedirectPage) {
@@ -1347,15 +1353,15 @@ class PageService implements IPageService {
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
     }
 
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
 
     const pageIds = pages.map(page => page._id);
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
 
     // Mapping to set to the body of the new revision
-    const pageIdRevisionMapping = {};
+    const pageIdRevisionMapping: Record<string, IRevisionDocument> = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
+      pageIdRevisionMapping[getIdStringForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1390,7 +1396,7 @@ class PageService implements IPageService {
           revision: revisionId,
         };
         newRevisions.push({
-          _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+          _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id.toString()].body, author: user._id, format: 'markdown',
         });
         newPages.push(newPage);
       }
@@ -1408,9 +1414,9 @@ class PageService implements IPageService {
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
 
     // Mapping to set to the body of the new revision
-    const pageIdRevisionMapping = {};
+    const pageIdRevisionMapping: Record<string, IRevisionDocument> = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
+      pageIdRevisionMapping[getIdStringForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1436,7 +1442,7 @@ class PageService implements IPageService {
       });
 
       newRevisions.push({
-        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id.toString()].body, author: user._id, format: 'markdown',
       });
 
     });
@@ -1705,8 +1711,8 @@ class PageService implements IPageService {
     // no sub operation available
   }
 
-  private async deletePageV4(page, user, options = {}, isRecursively = false) {
-    const Page = mongoose.model('Page') as PageModel;
+  private async deletePageV4(page: HydratedDocument<PageDocument>, user, options = {}, isRecursively = false) {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -2579,7 +2585,7 @@ class PageService implements IPageService {
     return infoForEntity;
   }
 
-  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user?): Promise<Record<string, string | null>> {
+  async shortBodiesMapByPageIds(pageIds: ObjectIdLike[] = [], user?): Promise<Record<string, string | null>> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const MAX_LENGTH = 350;
 
@@ -3591,8 +3597,8 @@ class PageService implements IPageService {
    * @param path string
    * @returns Promise<PageDocument>
    */
-  async getParentAndFillAncestorsByUser(user, path: string): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+  async getParentAndFillAncestorsByUser(user, path: string): Promise<HydratedDocument<PageDocument>> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
     // Find parent
     const parent = await Page.findParentByPath(path);
@@ -3618,8 +3624,8 @@ class PageService implements IPageService {
     return createdParent;
   }
 
-  async getParentAndFillAncestorsBySystem(path: string): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+  async getParentAndFillAncestorsBySystem(path: string): Promise<HydratedDocument<PageDocument>> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
 
     // Find parent
     const parent = await Page.findParentByPath(path);
@@ -4036,6 +4042,10 @@ class PageService implements IPageService {
     const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
+    if (savedPage._id == null) {
+      throw new Error('Something went wrong: _id is null');
+    }
+
     // Update descendantCount
     await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
 
@@ -4322,8 +4332,9 @@ class PageService implements IPageService {
   /*
    * Find all children by parent's path or id. Using id should be prioritized
    */
-  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null)
+      : Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
     let queryBuilder: PageQueryBuilder;
     if (hasSlash(parentPathOrId)) {
       const path = parentPathOrId;
@@ -4337,7 +4348,7 @@ class PageService implements IPageService {
     }
     await queryBuilder.addViewerCondition(user, userGroups);
 
-    const pages = await queryBuilder
+    const pages: HydratedDocument<PageDocument>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()
       .query
       .lean()
@@ -4413,7 +4424,7 @@ class PageService implements IPageService {
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    */
   private async injectProcessDataIntoPagesByActionTypes(
-      pages: (PageDocument & { processData?: IPageOperationProcessData })[],
+      pages: (HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[],
       actionTypes: PageActionType[],
   ): Promise<void> {
 

+ 3 - 3
apps/app/src/server/service/page/page-service.ts

@@ -4,7 +4,7 @@ import type {
   HasObjectId,
   IPageInfo, IPageInfoForEntity, IUser,
 } from '@growi/core';
-import type { ObjectId } from 'mongoose';
+import type { Types } from 'mongoose';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
@@ -17,12 +17,12 @@ export interface IPageService {
   forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
   updatePage(pageData: PageDocument, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate,): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
-  deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
+  deleteCompletelyOperation: (pageIds: ObjectIdLike[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
-  shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
+  shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | Omit<IPageInfoForEntity, 'bookmarkCount'>,
   canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
   canDeleteCompletely(

+ 2 - 2
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -1,4 +1,4 @@
-import { getIdForRef } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import type { HydratedDocument } from 'mongoose';
 import mongoose, { Types } from 'mongoose';
 
@@ -45,7 +45,7 @@ describe('normalizeLatestRevisionIfBroken', () => {
     assert(revisionById != null);
     assert(revisionByPageId != null);
     expect(revisionById._id).toEqual(revisionByPageId._id);
-    expect(getIdForRef(revisionById.pageId)).toEqual(page._id.toString());
+    expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString());
   });
 
 

+ 3 - 3
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,7 +1,7 @@
 import { Writable, Transform } from 'stream';
 import { URL } from 'url';
 
-import { getIdForRef, type IPage } from '@growi/core';
+import { getIdStringForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
@@ -357,8 +357,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    * generate object that is related to page.grant*
    */
   generateDocContentsRelatedToRestriction(page: AggregatedPage) {
-    const grantedUserIds = page.grantedUsers.map(user => getIdForRef(user));
-    const grantedGroupIds = page.grantedGroups.map(group => getIdForRef(group.item));
+    const grantedUserIds = page.grantedUsers.map(user => getIdStringForRef(user));
+    const grantedGroupIds = page.grantedGroups.map(group => getIdStringForRef(group.item));
 
     return {
       grant: page.grant,

+ 1 - 1
apps/app/src/server/util/granted-group.ts

@@ -1,6 +1,6 @@
 import { type IGrantedGroup, GroupType } from '@growi/core';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 export const divideByType = (grantedGroups: IGrantedGroup[] | null): {
   grantedUserGroups: ObjectIdLike[];

+ 1 - 1
apps/app/test/integration/service/page.test.js

@@ -671,7 +671,7 @@ describe('PageService', () => {
       expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
 
       expect([insertedRevision]).not.toBeNull();
-      expect(insertedRevision.pageId).toEqual(insertedPage._id.toString());
+      expect(insertedRevision.pageId).toEqual(insertedPage._id);
       expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
       expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
 

+ 8 - 8
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -1289,8 +1289,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
       expect(duplicatedRevision2.body).toBe(_revision2.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
-      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
     });
     test('Should duplicate multiple pages. Page with GRANT_RESTRICTED should NOT be duplicated', async() => {
       const _path1 = '/np_duplicate4';
@@ -1329,8 +1329,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(baseRevision1.body);
       expect(duplicatedRevision3.body).toBe(baseRevision3.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
-      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
     test('Should duplicate only user related pages and granted groups when onlyDuplicateUserRelatedResources is true', async() => {
       const _path1 = '/np_duplicate7';
@@ -1364,7 +1364,7 @@ describe('PageService page operations with non-public pages', () => {
       ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
     });
     test('Should duplicate all pages and granted groups when onlyDuplicateUserRelatedResources is false', async() => {
       const _path1 = '/np_duplicate7';
@@ -1410,18 +1410,18 @@ describe('PageService page operations with non-public pages', () => {
       ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
         { item: groupIdC, type: GroupType.userGroup },
         { item: externalGroupIdC, type: GroupType.externalUserGroup },
       ]);
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision2.body).toBe(_revision2.body);
-      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id.toString());
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
       expect(duplicatedPage3.grantedUsers).toStrictEqual([npDummyUser2._id]);
       expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.body).toBe(_revision3.body);
-      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id.toString());
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
 
   });

+ 2 - 1
packages/core/package.json

@@ -66,7 +66,8 @@
   },
   "dependencies": {
     "bson-objectid": "^2.0.4",
-    "escape-string-regexp": "^4.0.0"
+    "escape-string-regexp": "^4.0.0",
+    "mongoose": "^6.11.3"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 110 - 0
packages/core/src/interfaces/common.spec.ts

@@ -0,0 +1,110 @@
+import type { HydratedDocument } from 'mongoose';
+import { Types } from 'mongoose';
+import { mock } from 'vitest-mock-extended';
+
+import { getIdForRef, isPopulated } from './common';
+import type { IPageHasId } from './page';
+import { type IPage } from './page';
+
+describe('isPopulated', () => {
+
+  it('should return true when the argument implements HasObjectId', () => {
+    // Arrange
+    const ref = mock<IPageHasId>();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(true);
+  });
+
+  it('should return true when the argument is a mongoose Document', () => {
+    // Arrange
+    const ref = mock<HydratedDocument<IPage>>();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(true);
+  });
+
+  it('should return false when the argument is string', () => {
+    // Arrange
+    const ref = new Types.ObjectId().toString();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(false);
+  });
+
+  it('should return false when the argument is ObjectId', () => {
+    // Arrange
+    const ref = new Types.ObjectId();
+
+    // Act
+    const result = isPopulated(ref);
+
+    // Assert
+    expect(result).toBe(false);
+  });
+
+});
+
+
+describe('getIdForRef', () => {
+
+  it('should return the id string when the argument is populated', () => {
+    // Arrange
+    const id = new Types.ObjectId();
+    const ref = mock<IPageHasId>({
+      _id: id.toString(),
+    });
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(id.toString());
+  });
+
+  it('should return the ObjectId when the argument is a mongoose Document', () => {
+    // Arrange
+    const id = new Types.ObjectId();
+    const ref = mock<HydratedDocument<IPage>>({
+      _id: id,
+    });
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(id);
+  });
+
+  it('should return the id string as is when the argument is ObjectId', () => {
+    // Arrange
+    const ref = new Types.ObjectId();
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(ref);
+  });
+
+  it('should return the ObjectId as is when the argument is string', () => {
+    // Arrange
+    const ref = new Types.ObjectId().toString();
+
+    // Act
+    const result = getIdForRef(ref);
+
+    // Assert
+    expect(result).toStrictEqual(ref);
+  });
+
+});

+ 13 - 5
packages/core/src/interfaces/common.ts

@@ -2,20 +2,28 @@
  * Common types and interfaces
  */
 
-import type { HasObjectId } from './has-object-id';
 
+import type { Types } from 'mongoose';
+
+type ObjectId = Types.ObjectId;
 
 // Foreign key field
-export type Ref<T> = string | T & HasObjectId;
+export type Ref<T> = string | ObjectId | T & { _id: string | ObjectId };
 
 export type Nullable<T> = T | null | undefined;
 
-export const isPopulated = <T>(ref: T & HasObjectId | Ref<T>): ref is T & HasObjectId => {
-  return !(typeof ref === 'string');
+export const isPopulated = <T>(ref: Ref<T>): ref is T & { _id: string | ObjectId } => {
+  return ref != null
+    && typeof ref !== 'string'
+    && !('_bsontype' in ref && ref._bsontype === 'ObjectID');
 };
 
-export const getIdForRef = <T>(ref: T & HasObjectId | Ref<T>): string => {
+export const getIdForRef = <T>(ref: Ref<T>): string | ObjectId => {
   return isPopulated(ref)
     ? ref._id
     : ref;
 };
+
+export const getIdStringForRef = <T>(ref: Ref<T>): string => {
+  return getIdForRef(ref).toString();
+};

+ 1 - 1
packages/core/src/interfaces/user.ts

@@ -37,7 +37,7 @@ export type IUserGroup = {
   name: string;
   createdAt: Date;
   description: string;
-  parent: Ref<IUserGroupHasId> | null;
+  parent: Ref<IUserGroup> | null;
 }
 
 export const USER_STATUS = {