0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hybrid License System Day 17: ユーザー管理API実装

Last updated at Posted at 2025-12-16

🎄 科学と神々株式会社 アドベントカレンダー 2025

Hybrid License System Day 17: ユーザー管理API実装

Admin Service編 (2/5)


📖 はじめに

Day 17では、ユーザー管理API実装を学びます。CRUD操作、検索とフィルタリング、ページネーション、ソート機能を実装しましょう。


🛣️ RESTful API設計

エンドポイント一覧

Method Endpoint 説明 必要権限
GET /api/admin/users ユーザー一覧取得 users.read
GET /api/admin/users/:userId ユーザー詳細取得 users.read
POST /api/admin/users ユーザー作成 users.write
PUT /api/admin/users/:userId ユーザー更新 users.write
DELETE /api/admin/users/:userId ユーザー削除 users.delete
GET /api/admin/users/search ユーザー検索 users.read
PUT /api/admin/users/:userId/plan プラン変更 users.write
PUT /api/admin/users/:userId/status ステータス変更 users.write

📊 ユーザー一覧API

ページネーション + フィルタリング + ソート

// admin-service/src/controllers/userController.js
class UserController {
  constructor(db) {
    this.db = db;
  }

  /**
   * ユーザー一覧取得
   * クエリパラメータ:
   *   - page: ページ番号(デフォルト: 1)
   *   - pageSize: 1ページあたりの件数(デフォルト: 20)
   *   - sortBy: ソートフィールド(created_at, email, plan)
   *   - sortOrder: ソート順序(asc, desc)
   *   - plan: プランフィルター(free, professional, enterprise)
   *   - search: 検索キーワード(email部分一致)
   */
  async getUsers(req, res) {
    try {
      const {
        page = 1,
        pageSize = 20,
        sortBy = 'created_at',
        sortOrder = 'desc',
        plan,
        search
      } = req.query;

      // バリデーション
      const validSortFields = ['created_at', 'email', 'plan', 'user_id'];
      const validSortOrders = ['asc', 'desc'];

      if (!validSortFields.includes(sortBy)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid sortBy field'
        });
      }

      if (!validSortOrders.includes(sortOrder.toLowerCase())) {
        return res.status(400).json({
          success: false,
          error: 'Invalid sortOrder'
        });
      }

      // クエリ構築
      let whereClause = '1=1';
      const params = [];

      // プランフィルター
      if (plan) {
        whereClause += ' AND plan = ?';
        params.push(plan);
      }

      // 検索フィルター
      if (search) {
        whereClause += ' AND email LIKE ?';
        params.push(`%${search}%`);
      }

      // 総件数取得
      const countStmt = this.db.prepare(`
        SELECT COUNT(*) as count
        FROM users
        WHERE ${whereClause}
      `);
      const { count: total } = countStmt.get(...params);

      // データ取得
      const offset = (parseInt(page) - 1) * parseInt(pageSize);
      const dataStmt = this.db.prepare(`
        SELECT user_id, email, plan, created_at, updated_at
        FROM users
        WHERE ${whereClause}
        ORDER BY ${sortBy} ${sortOrder.toUpperCase()}
        LIMIT ? OFFSET ?
      `);

      const users = dataStmt.all(...params, parseInt(pageSize), offset);

      // レスポンス
      res.json({
        success: true,
        data: users,
        pagination: {
          page: parseInt(page),
          pageSize: parseInt(pageSize),
          total,
          totalPages: Math.ceil(total / parseInt(pageSize))
        }
      });
    } catch (error) {
      console.error('Get users error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }

  /**
   * ユーザー詳細取得
   */
  async getUserById(req, res) {
    try {
      const { userId } = req.params;

      // ユーザー取得
      const user = this.db.prepare(`
        SELECT user_id, email, plan, created_at, updated_at
        FROM users
        WHERE user_id = ?
      `).get(userId);

      if (!user) {
        return res.status(404).json({
          success: false,
          error: 'User not found'
        });
      }

      // 関連ライセンス取得
      const licenses = this.db.prepare(`
        SELECT license_id, client_id, status, created_at, expires_at
        FROM licenses
        WHERE user_id = ?
        ORDER BY created_at DESC
      `).all(userId);

      // 統計情報
      const stats = {
        totalLicenses: licenses.length,
        activeLicenses: licenses.filter(l => l.status === 'active').length,
        expiredLicenses: licenses.filter(l => l.status === 'expired').length
      };

      res.json({
        success: true,
        data: {
          user,
          licenses,
          stats
        }
      });
    } catch (error) {
      console.error('Get user error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }
}

module.exports = UserController;

➕ ユーザー作成API

class UserController {
  /**
   * ユーザー作成
   */
  async createUser(req, res) {
    try {
      const { email, password, plan = 'free' } = req.body;

      // バリデーション
      if (!email || !password) {
        return res.status(400).json({
          success: false,
          error: 'Email and password are required'
        });
      }

      // メールアドレス形式チェック
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(email)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid email format'
        });
      }

      // パスワード強度チェック
      if (password.length < 8) {
        return res.status(400).json({
          success: false,
          error: 'Password must be at least 8 characters'
        });
      }

      // プランチェック
      const validPlans = ['free', 'professional', 'enterprise'];
      if (!validPlans.includes(plan)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid plan'
        });
      }

      // 重複チェック
      const existing = this.db.prepare(
        'SELECT * FROM users WHERE email = ?'
      ).get(email);

      if (existing) {
        return res.status(409).json({
          success: false,
          error: 'User already exists'
        });
      }

      // パスワードハッシュ化
      const bcrypt = require('bcrypt');
      const passwordHash = await bcrypt.hash(password, 10);

      // ユーザー作成(トランザクション)
      const transaction = this.db.transaction(() => {
        const userId = `user-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
        const now = new Date().toISOString();

        // ユーザー挿入
        this.db.prepare(`
          INSERT INTO users (user_id, email, password_hash, plan, created_at, updated_at)
          VALUES (?, ?, ?, ?, ?, ?)
        `).run(userId, email, passwordHash, plan, now, now);

        // 監査ログ
        this.logAudit(req.user.admin_id, 'USER_CREATED', {
          userId,
          email,
          plan
        });

        return this.db.prepare(
          'SELECT user_id, email, plan, created_at FROM users WHERE user_id = ?'
        ).get(userId);
      });

      const newUser = transaction();

      res.status(201).json({
        success: true,
        data: newUser
      });
    } catch (error) {
      console.error('Create user error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }

  /**
   * 監査ログ記録
   */
  logAudit(adminId, action, details) {
    const logId = `log-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
    const now = new Date().toISOString();

    this.db.prepare(`
      INSERT INTO audit_logs (log_id, user_id, action, details, timestamp)
      VALUES (?, ?, ?, ?, ?)
    `).run(logId, adminId, action, JSON.stringify(details), now);
  }
}

✏️ ユーザー更新API

class UserController {
  /**
   * ユーザー更新
   */
  async updateUser(req, res) {
    try {
      const { userId } = req.params;
      const { email, plan } = req.body;

      // ユーザー存在確認
      const user = this.db.prepare(
        'SELECT * FROM users WHERE user_id = ?'
      ).get(userId);

      if (!user) {
        return res.status(404).json({
          success: false,
          error: 'User not found'
        });
      }

      // 更新内容準備
      const updates = [];
      const params = [];

      if (email) {
        // メールアドレス形式チェック
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(email)) {
          return res.status(400).json({
            success: false,
            error: 'Invalid email format'
          });
        }

        // 重複チェック
        const existing = this.db.prepare(
          'SELECT * FROM users WHERE email = ? AND user_id != ?'
        ).get(email, userId);

        if (existing) {
          return res.status(409).json({
            success: false,
            error: 'Email already in use'
          });
        }

        updates.push('email = ?');
        params.push(email);
      }

      if (plan) {
        const validPlans = ['free', 'professional', 'enterprise'];
        if (!validPlans.includes(plan)) {
          return res.status(400).json({
            success: false,
            error: 'Invalid plan'
          });
        }

        updates.push('plan = ?');
        params.push(plan);
      }

      if (updates.length === 0) {
        return res.status(400).json({
          success: false,
          error: 'No fields to update'
        });
      }

      // updated_at追加
      updates.push('updated_at = ?');
      params.push(new Date().toISOString());

      // トランザクション
      const transaction = this.db.transaction(() => {
        // ユーザー更新
        this.db.prepare(`
          UPDATE users
          SET ${updates.join(', ')}
          WHERE user_id = ?
        `).run(...params, userId);

        // 監査ログ
        this.logAudit(req.user.admin_id, 'USER_UPDATED', {
          userId,
          changes: { email, plan }
        });

        return this.db.prepare(
          'SELECT user_id, email, plan, created_at, updated_at FROM users WHERE user_id = ?'
        ).get(userId);
      });

      const updatedUser = transaction();

      res.json({
        success: true,
        data: updatedUser
      });
    } catch (error) {
      console.error('Update user error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }

  /**
   * プラン変更
   */
  async updatePlan(req, res) {
    try {
      const { userId } = req.params;
      const { plan } = req.body;

      const validPlans = ['free', 'professional', 'enterprise'];
      if (!validPlans.includes(plan)) {
        return res.status(400).json({
          success: false,
          error: 'Invalid plan'
        });
      }

      const transaction = this.db.transaction(() => {
        const user = this.db.prepare('SELECT * FROM users WHERE user_id = ?').get(userId);

        if (!user) {
          throw new Error('User not found');
        }

        this.db.prepare(`
          UPDATE users
          SET plan = ?, updated_at = ?
          WHERE user_id = ?
        `).run(plan, new Date().toISOString(), userId);

        this.logAudit(req.user.admin_id, 'PLAN_CHANGED', {
          userId,
          oldPlan: user.plan,
          newPlan: plan
        });

        return this.db.prepare('SELECT user_id, email, plan FROM users WHERE user_id = ?').get(userId);
      });

      const updated = transaction();

      res.json({
        success: true,
        data: updated
      });
    } catch (error) {
      if (error.message === 'User not found') {
        return res.status(404).json({
          success: false,
          error: error.message
        });
      }

      console.error('Update plan error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }
}

🗑️ ユーザー削除API

class UserController {
  /**
   * ユーザー削除(論理削除推奨)
   */
  async deleteUser(req, res) {
    try {
      const { userId } = req.params;

      const transaction = this.db.transaction(() => {
        const user = this.db.prepare('SELECT * FROM users WHERE user_id = ?').get(userId);

        if (!user) {
          throw new Error('User not found');
        }

        // 関連ライセンス取得
        const licenses = this.db.prepare(
          'SELECT * FROM licenses WHERE user_id = ?'
        ).all(userId);

        // ユーザー削除(CASCADE設定により関連ライセンスも削除)
        this.db.prepare('DELETE FROM users WHERE user_id = ?').run(userId);

        // 監査ログ
        this.logAudit(req.user.admin_id, 'USER_DELETED', {
          userId,
          email: user.email,
          deletedLicenses: licenses.length
        });

        return {
          deletedUser: {
            userId: user.user_id,
            email: user.email
          },
          deletedLicenses: licenses.length
        };
      });

      const result = transaction();

      res.json({
        success: true,
        data: result
      });
    } catch (error) {
      if (error.message === 'User not found') {
        return res.status(404).json({
          success: false,
          error: error.message
        });
      }

      console.error('Delete user error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }
}

🔍 検索API

class UserController {
  /**
   * ユーザー検索(複合条件)
   */
  async searchUsers(req, res) {
    try {
      const {
        email,
        plan,
        createdAfter,
        createdBefore,
        page = 1,
        pageSize = 20
      } = req.query;

      let whereClause = '1=1';
      const params = [];

      // メール検索(部分一致)
      if (email) {
        whereClause += ' AND email LIKE ?';
        params.push(`%${email}%`);
      }

      // プランフィルター
      if (plan) {
        whereClause += ' AND plan = ?';
        params.push(plan);
      }

      // 作成日フィルター
      if (createdAfter) {
        whereClause += ' AND created_at >= ?';
        params.push(createdAfter);
      }

      if (createdBefore) {
        whereClause += ' AND created_at <= ?';
        params.push(createdBefore);
      }

      // 総件数
      const countStmt = this.db.prepare(`
        SELECT COUNT(*) as count
        FROM users
        WHERE ${whereClause}
      `);
      const { count: total } = countStmt.get(...params);

      // データ取得
      const offset = (parseInt(page) - 1) * parseInt(pageSize);
      const dataStmt = this.db.prepare(`
        SELECT user_id, email, plan, created_at, updated_at
        FROM users
        WHERE ${whereClause}
        ORDER BY created_at DESC
        LIMIT ? OFFSET ?
      `);

      const users = dataStmt.all(...params, parseInt(pageSize), offset);

      res.json({
        success: true,
        data: users,
        pagination: {
          page: parseInt(page),
          pageSize: parseInt(pageSize),
          total,
          totalPages: Math.ceil(total / parseInt(pageSize))
        }
      });
    } catch (error) {
      console.error('Search users error:', error);
      res.status(500).json({
        success: false,
        error: 'Internal Server Error'
      });
    }
  }
}

🧪 テストケース

// admin-service/tests/userController.test.js
const request = require('supertest');
const app = require('../src/app');

describe('User Management API', () => {
  let adminToken;

  beforeAll(async () => {
    // 管理者ログインしてトークン取得
    const res = await request(app)
      .post('/api/admin/login')
      .send({ email: 'admin@example.com', password: 'admin123' });

    adminToken = res.body.data.token;
  });

  describe('GET /api/admin/users', () => {
    it('should get users with pagination', async () => {
      const res = await request(app)
        .get('/api/admin/users?page=1&pageSize=10')
        .set('Authorization', `Bearer ${adminToken}`);

      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.data).toBeInstanceOf(Array);
      expect(res.body.pagination).toHaveProperty('total');
    });

    it('should filter users by plan', async () => {
      const res = await request(app)
        .get('/api/admin/users?plan=professional')
        .set('Authorization', `Bearer ${adminToken}`);

      expect(res.status).toBe(200);
      res.body.data.forEach(user => {
        expect(user.plan).toBe('professional');
      });
    });
  });

  describe('POST /api/admin/users', () => {
    it('should create a new user', async () => {
      const res = await request(app)
        .post('/api/admin/users')
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          email: 'newuser@example.com',
          password: 'SecurePass123',
          plan: 'free'
        });

      expect(res.status).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data.email).toBe('newuser@example.com');
    });

    it('should reject duplicate email', async () => {
      const res = await request(app)
        .post('/api/admin/users')
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          email: 'newuser@example.com',
          password: 'SecurePass123',
          plan: 'free'
        });

      expect(res.status).toBe(409);
      expect(res.body.error).toContain('already exists');
    });
  });
});

🎯 次のステップ

Day 18では、統計・分析機能を学びます。リアルタイム統計、アクティブユーザー数、ライセンス使用状況、グラフデータ生成について詳しく解説します。


🔗 関連リンク


次回予告: Day 18では、リアルタイムダッシュボードとデータ可視化APIを詳しく解説します!


Copyright © 2025 Gods & Golem, Inc. All rights reserved.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?