Browse Source

feat(news): add admin endpoints for delivery setting

Add `GET /apiv3/news/admin/delivery-setting` and
`POST /apiv3/news/admin/delivery-setting` so an admin can read and toggle
`news:isDeliveryEnabled` over HTTP. Both endpoints require
`SCOPE.WRITE.ADMIN.APP` and pass through `adminRequired`. The POST
handler validates `flag` as a boolean and persists via
`configManager.updateConfigs`, which updates the in-memory cache and
broadcasts to peer pods via the existing pubsub.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ryotaro Nagahara 1 week ago
parent
commit
c3ce81fa6b

+ 70 - 0
apps/app/src/features/news/server/routes/news.spec.ts

@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => {
   const getUnreadCount = vi.fn();
   const getUnreadCount = vi.fn();
   const markRead = vi.fn();
   const markRead = vi.fn();
   const markAllRead = vi.fn();
   const markAllRead = vi.fn();
+  const getConfig = vi.fn<(key: string) => unknown>();
+  const updateConfigs = vi.fn();
   return {
   return {
     NewsService: vi.fn(() => ({
     NewsService: vi.fn(() => ({
       listForUser,
       listForUser,
@@ -20,6 +22,8 @@ const mocks = vi.hoisted(() => {
     getUnreadCount,
     getUnreadCount,
     markRead,
     markRead,
     markAllRead,
     markAllRead,
+    getConfig,
+    updateConfigs,
   };
   };
 });
 });
 
 
@@ -27,6 +31,13 @@ vi.mock('../services/news-service', () => ({
   NewsService: mocks.NewsService,
   NewsService: mocks.NewsService,
 }));
 }));
 
 
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+    updateConfigs: mocks.updateConfigs,
+  },
+}));
+
 // Middleware mocks - bypass auth
 // Middleware mocks - bypass auth
 vi.mock('~/server/middlewares/access-token-parser', () => ({
 vi.mock('~/server/middlewares/access-token-parser', () => ({
   accessTokenParser: () => (_req: unknown, _res: unknown, next: () => void) =>
   accessTokenParser: () => (_req: unknown, _res: unknown, next: () => void) =>
@@ -286,4 +297,63 @@ describe('News API routes', () => {
       expect(mocks.markAllRead).toHaveBeenCalled();
       expect(mocks.markAllRead).toHaveBeenCalled();
     });
     });
   });
   });
+
+  describe('GET /apiv3/news/admin/delivery-setting', () => {
+    test('should return current value from configManager', async () => {
+      mocks.getConfig.mockReturnValue(true);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: true });
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+    });
+
+    test('should reflect false when delivery is disabled', async () => {
+      mocks.getConfig.mockReturnValue(false);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+    });
+  });
+
+  describe('POST /apiv3/news/admin/delivery-setting', () => {
+    test('should update delivery setting via configManager', async () => {
+      mocks.updateConfigs.mockResolvedValue(undefined);
+
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: false });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+      expect(mocks.updateConfigs).toHaveBeenCalledWith({
+        'news:isDeliveryEnabled': false,
+      });
+    });
+
+    test('should return 400 when flag is not boolean', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: 'true' });
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+
+    test('should return 400 when flag is missing', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({});
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+  });
 });
 });

+ 55 - 0
apps/app/src/features/news/server/routes/news.ts

@@ -6,7 +6,9 @@ import mongoose from 'mongoose';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
 import loginRequiredFactory from '~/server/middlewares/login-required';
 import loginRequiredFactory from '~/server/middlewares/login-required';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { NewsService } from '../services/news-service';
 import { NewsService } from '../services/news-service';
@@ -55,6 +57,10 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
     crowi != null
     crowi != null
       ? loginRequiredFactory(crowi)
       ? loginRequiredFactory(crowi)
       : (_req: unknown, _res: unknown, next: () => void) => next();
       : (_req: unknown, _res: unknown, next: () => void) => next();
+  const adminRequired =
+    crowi != null
+      ? adminRequiredFactory(crowi)
+      : (_req: unknown, _res: unknown, next: () => void) => next();
 
 
   /**
   /**
    * GET /news/list
    * GET /news/list
@@ -180,6 +186,55 @@ export const createNewsRouter = (crowi?: Crowi): express.Router => {
     },
     },
   );
   );
 
 
+  /**
+   * GET /news/admin/delivery-setting
+   * Returns the current value of `news:isDeliveryEnabled` (admin only)
+   */
+  router.get(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    (_req, res) => {
+      try {
+        const isDeliveryEnabled = configManager.getConfig(
+          'news:isDeliveryEnabled',
+        );
+        return res.json({ isDeliveryEnabled });
+      } catch (err) {
+        logger.error('GET /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/admin/delivery-setting
+   * Updates `news:isDeliveryEnabled` (admin only). Body: `{ flag: boolean }`.
+   * The new value is persisted to the `Config` collection and reflected on
+   * the next cron tick without a restart.
+   */
+  router.post(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const { flag } = req.body;
+        if (typeof flag !== 'boolean') {
+          return res.status(400).json({ error: '`flag` must be a boolean' });
+        }
+
+        await configManager.updateConfigs({ 'news:isDeliveryEnabled': flag });
+        return res.json({ isDeliveryEnabled: flag });
+      } catch (err) {
+        logger.error('POST /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
   return router;
   return router;
 };
 };