← back to forestnuri713-crypto__project

Function bodies 156 total

All specs Real LLM only Function bodies
BulkCancelPage function · typescript · L4-L10 (7 LOC)
apps/admin/src/app/bulk-cancel/page.tsx
export default function BulkCancelPage() {
  return (
    <Suspense fallback={null}>
      <BulkCancelClient />
    </Suspense>
  );
}
toCertArray function · typescript · L30-L40 (11 LOC)
apps/admin/src/app/instructors/[id]/page.tsx
function toCertArray(input: unknown): Certification[] {
  if (!Array.isArray(input)) return [];
  return input
    .filter((x) => x && typeof x === 'object')
    .map((x: any) => ({
      type: String(x.type ?? ''),
      label: String(x.label ?? ''),
      iconType: x.iconType ? String(x.iconType) : undefined,
    }))
    .filter((x) => x.type && x.label);
}
certsCount function · typescript · L43-L51 (9 LOC)
apps/admin/src/app/instructors/page.tsx
function certsCount(certs: unknown): number {
  if (Array.isArray(certs)) return certs.length;
  if (certs && typeof certs === "object") {
    // sometimes stored as JSON object; count values conservatively
    const v = Object.values(certs as Record<string, unknown>);
    return Array.isArray(v) ? v.length : 0;
  }
  return 0;
}
InstructorsPage function · typescript · L53-L210 (158 LOC)
apps/admin/src/app/instructors/page.tsx
export default function InstructorsPage() {
  const [tab, setTab] = useState<"ALL" | InstructorStatus>("APPLIED");
  const [search, setSearch] = useState<string>("");
  const [page, setPage] = useState<number>(1);

  const [data, setData] = useState<Paginated<Instructor> | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<{ message: string; requestId: string | null } | null>(null);

  const debounceRef = useRef<NodeJS.Timeout | null>(null);

  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    params.set("page", String(page));
    params.set("limit", "20");

    if (tab !== "ALL") params.set("instructorStatus", tab);
    if (search.trim().length > 0) params.set("search", search.trim());

    return params.toString();
  }, [page, tab, search]);

  const fetchList = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await api.get<Paginated<Instructor>>(`/ad
RootLayout function · typescript · L10-L18 (9 LOC)
apps/admin/src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}
LoginPage function · typescript · L16-L100 (85 LOC)
apps/admin/src/app/login/page.tsx
export default function LoginPage() {
  const { user, isLoading, login } = useAuth();
  const router = useRouter();
  const [error, setError] = useState('');
  const [logging, setLogging] = useState(false);

  useEffect(() => {
    if (!isLoading && user?.role === 'ADMIN') {
      router.replace('/');
    }
  }, [user, isLoading, router]);

  useEffect(() => {
    if (!KAKAO_JS_KEY) return;
    const script = document.createElement('script');
    script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js';
    script.onload = () => {
      if (window.Kakao && !window.Kakao.isInitialized()) {
        window.Kakao.init(KAKAO_JS_KEY);
      }
    };
    document.head.appendChild(script);
  }, []);

  const handleKakaoLogin = () => {
    if (!window.Kakao) {
      setError('Kakao SDK가 로드되지 않았습니다');
      return;
    }

    window.Kakao.Auth.login({
      success: async (authObj: { access_token: string }) => {
        setLogging(true);
        setError('');
        try {
        
DashboardPage function · typescript · L84-L198 (115 LOC)
apps/admin/src/app/page.tsx
export default function DashboardPage() {
  const [stats, setStats] = useState<DashboardStats | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<ErrorState | null>(null);

  const fetchStats = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await api.get<DashboardStats>('/admin/dashboard/stats');
      setStats(data);
    } catch (e) {
      if (e instanceof ApiError) {
        setError({ message: e.message, code: e.code, requestId: e.requestId });
      } else {
        setError({ message: '대시보드 통계를 불러오지 못했습니다', code: null, requestId: null });
      }
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    fetchStats();
  }, [fetchStats]);

  // M2: Compute max value for proportional bar widths (no derived metrics — just layout scaling)
  const maxValue = stats
    ? Math.max(...CARD_CONFIGS.map((c) => stats[c.key]), 1)
    : 1;

  return (
    <AdminLayout>
    
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
ProgramsPendingPage function · typescript · L23-L108 (86 LOC)
apps/admin/src/app/programs/pending/page.tsx
export default function ProgramsPendingPage() {
  const [data, setData] = useState<ProgramsResponse | null>(null);
  const [page, setPage] = useState(1);

  const load = useCallback(() => {
    api
      .get<ProgramsResponse>(
        `/admin/programs?approvalStatus=PENDING_REVIEW&page=${page}&limit=20`,
      )
      .then(setData);
  }, [page]);

  useEffect(() => {
    load();
  }, [load]);

  const handleApprove = async (id: string) => {
    if (!confirm('승인하시겠습니까?')) return;
    await api.patch(`/admin/programs/${id}/approve`);
    load();
  };

  const handleReject = async (id: string) => {
    const reason = prompt('거절 사유를 입력하세요');
    if (!reason) return;
    await api.patch(`/admin/programs/${id}/reject`, { rejectionReason: reason });
    load();
  };

  return (
    <AdminLayout>
      <h2 className="text-xl font-bold mb-6">승인 대기 프로그램</h2>
      {!data ? (
        <p className="text-gray-500">로딩 중...</p>
      ) : data.items.length === 0 ? (
        <p className="text-gray-5
ReviewsPage function · typescript · L26-L153 (128 LOC)
apps/admin/src/app/reviews/page.tsx
export default function ReviewsPage() {
  const [data, setData] = useState<ReviewsResponse | null>(null);
  const [page, setPage] = useState(1);
  const [statusFilter, setStatusFilter] = useState('');
  const [ratingFilter, setRatingFilter] = useState('');

  const load = useCallback(() => {
    const params = new URLSearchParams({ page: String(page), limit: '20' });
    if (statusFilter) params.set('status', statusFilter);
    if (ratingFilter) params.set('rating', ratingFilter);
    api.get<ReviewsResponse>(`/admin/reviews?${params}`).then(setData);
  }, [page, statusFilter, ratingFilter]);

  useEffect(() => {
    load();
  }, [load]);

  const handleSetStatus = async (id: string, newStatus: 'VISIBLE' | 'HIDDEN') => {
    await api.patch(`/admin/reviews/${id}/status`, { status: newStatus });
    load();
  };

  const renderStars = (rating: number) => {
    return '★'.repeat(rating) + '☆'.repeat(5 - rating);
  };

  return (
    <AdminLayout>
      <h2 className="text-xl font-bold mb
SettlementsPage function · typescript · L34-L136 (103 LOC)
apps/admin/src/app/settlements/page.tsx
export default function SettlementsPage() {
  const [data, setData] = useState<SettlementsResponse | null>(null);
  const [page, setPage] = useState(1);
  const [status, setStatus] = useState('');

  const load = useCallback(() => {
    const params = new URLSearchParams({ page: String(page), limit: '20' });
    if (status) params.set('status', status);
    api.get<SettlementsResponse>(`/admin/settlements?${params}`).then(setData);
  }, [page, status]);

  useEffect(() => {
    load();
  }, [load]);

  const handlePay = async (id: string) => {
    if (!confirm('지급 처리하시겠습니까?')) return;
    await api.patch(`/admin/settlements/${id}/pay`);
    load();
  };

  return (
    <AdminLayout>
      <h2 className="text-xl font-bold mb-6">정산 관리</h2>
      <div className="mb-4 flex gap-2">
        {STATUS_OPTIONS.map((s) => (
          <button
            key={s}
            onClick={() => {
              setStatus(s);
              setPage(1);
            }}
            className={`px-3 py-1.5 tex
UsersPage function · typescript · L32-L140 (109 LOC)
apps/admin/src/app/users/page.tsx
export default function UsersPage() {
  const [data, setData] = useState<UsersResponse | null>(null);
  const [page, setPage] = useState(1);
  const [role, setRole] = useState('');
  const [search, setSearch] = useState('');

  const load = useCallback(() => {
    const params = new URLSearchParams({ page: String(page), limit: '20' });
    if (role) params.set('role', role);
    if (search) params.set('search', search);
    api.get<UsersResponse>(`/admin/users?${params}`).then(setData);
  }, [page, role, search]);

  useEffect(() => {
    load();
  }, [load]);

  const handleRoleChange = async (userId: string, newRole: string) => {
    if (!confirm(`역할을 ${ROLE_LABELS[newRole] || newRole}(으)로 변경하시겠습니까?`)) return;
    await api.patch(`/admin/users/${userId}/role`, { role: newRole });
    load();
  };

  return (
    <AdminLayout>
      <h2 className="text-xl font-bold mb-6">유저 관리</h2>
      <div className="mb-4 flex gap-4 items-center">
        <div className="flex gap-2">
          {ROL
AdminLayout function · typescript · L7-L16 (10 LOC)
apps/admin/src/components/AdminLayout.tsx
export default function AdminLayout({ children }: { children: ReactNode }) {
  return (
    <ProtectedRoute>
      <div className="flex min-h-screen">
        <Sidebar />
        <main className="flex-1 bg-gray-50 p-8">{children}</main>
      </div>
    </ProtectedRoute>
  );
}
Pagination function · typescript · L10-L53 (44 LOC)
apps/admin/src/components/Pagination.tsx
export default function Pagination({ page, total, pageSize, onChange }: PaginationProps) {
  const totalPages = Math.ceil(total / pageSize);
  if (totalPages <= 1) return null;

  return (
    <div className="flex items-center justify-between mt-4">
      <p className="text-sm text-gray-600">
        총 {total}건 (페이지 {page}/{totalPages})
      </p>
      <div className="flex gap-1">
        <button
          disabled={page <= 1}
          onClick={() => onChange(page - 1)}
          className="px-3 py-1 text-sm border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
        >
          이전
        </button>
        {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
          const start = Math.max(1, Math.min(page - 2, totalPages - 4));
          const p = start + i;
          if (p > totalPages) return null;
          return (
            <button
              key={p}
              onClick={() => onChange(p)}
              className={`px-3 py-1 text-s
ProtectedRoute function · typescript · L7-L30 (24 LOC)
apps/admin/src/components/ProtectedRoute.tsx
export default function ProtectedRoute({ children }: { children: ReactNode }) {
  const { user, isLoading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!isLoading && (!user || user.role !== 'ADMIN')) {
      router.replace('/login');
    }
  }, [user, isLoading, router]);

  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <p className="text-gray-500">로딩 중...</p>
      </div>
    );
  }

  if (!user || user.role !== 'ADMIN') {
    return null;
  }

  return <>{children}</>;
}
Sidebar function · typescript · L18-L58 (41 LOC)
apps/admin/src/components/Sidebar.tsx
export default function Sidebar() {
  const pathname = usePathname();
  const { logout } = useAuth();

  return (
    <aside className="w-60 bg-gray-900 text-gray-100 min-h-screen flex flex-col">
      <div className="px-6 py-5 border-b border-gray-700">
        <h1 className="text-lg font-bold tracking-tight">숲똑 Admin</h1>
      </div>
      <nav className="flex-1 py-4">
        {NAV_ITEMS.map((item) => {
          const isActive =
            item.href === '/'
              ? pathname === '/'
              : pathname.startsWith(item.href);
          return (
            <Link
              key={item.href}
              href={item.href}
              className={`block px-6 py-2.5 text-sm transition-colors ${
                isActive
                  ? 'bg-gray-800 text-white font-medium'
                  : 'text-gray-400 hover:text-white hover:bg-gray-800'
              }`}
            >
              {item.label}
            </Link>
          );
        })}
      </nav>
      <div 
Generated by Repobility's multi-pass static-analysis pipeline (https://repobility.com)
AuthProvider function · typescript · L28-L73 (46 LOC)
apps/admin/src/contexts/AuthContext.tsx
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const savedToken = localStorage.getItem('token');
    const savedUser = localStorage.getItem('user');
    if (savedToken && savedUser) {
      try {
        const parsed = JSON.parse(savedUser);
        if (parsed.role === 'ADMIN') {
          setToken(savedToken);
          setUser(parsed);
        } else {
          localStorage.removeItem('token');
          localStorage.removeItem('user');
        }
      } catch {
        localStorage.removeItem('token');
        localStorage.removeItem('user');
      }
    }
    setIsLoading(false);
  }, []);

  const login = useCallback((newToken: string, newUser: User) => {
    setToken(newToken);
    setUser(newUser);
    localStorage.setItem('token', newToken);
    localStorage.setItem(
request function · typescript · L3-L34 (32 LOC)
apps/admin/src/services/api.ts
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...((options.headers as Record<string, string>) || {}),
  };

  if (token) {
    headers['Authorization'] = `Bearer ${token}`;
  }

  const res = await fetch(`${API_BASE}${path}`, {
    ...options,
    headers,
  });

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    const err = body?.error ?? body;

    throw new ApiError(
     res.status,
     err?.message || res.statusText,
     err?.code ?? null,
     err?.requestId ?? null,
    );
  }

  if (res.status === 204) return null as T;
  return res.json();
}
ApiError.constructor method · typescript · L37-L44 (8 LOC)
apps/admin/src/services/api.ts
  constructor(
    public status: number,
    message: string,
    public code: string | null = null,
    public requestId: string | null = null,
  ) {
    super(message);
  }
App function · typescript · L4-L12 (9 LOC)
apps/mobile/App.tsx
export default function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>숲똑</Text>
      <Text style={styles.subtitle}>숲체험 예약 플랫폼</Text>
      <StatusBar style="auto" />
    </View>
  );
}
AdminBulkCancelService.determineMode method · typescript · L82-L87 (6 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  private determineMode(): BulkCancelMode {
    const paymentsServicePresent =
      !!this.paymentsService &&
      typeof this.paymentsService.processRefund === 'function';
    return getRefundMode(paymentsServicePresent);
  }
AdminBulkCancelService.createJob method · typescript · L107-L186 (80 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  async createJob(
    sessionId: string,
    reason: string,
    adminUserId: string,
    dryRun = false,
  ): Promise<CreateJobDryRunResult | CreateJobCreatedResult> {
    const program = await this.prisma.program.findUnique({
      where: { id: sessionId },
    });
    if (!program) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_NOT_FOUND',
        '프로그램을 찾을 수 없습니다',
        404,
      );
    }

    const runningJob = await this.prisma.bulkCancelJob.findFirst({
      where: { sessionId, status: 'RUNNING' },
    });
    if (runningJob) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_RUNNING',
        '해당 프로그램에 이미 실행 중인 일괄 취소 작업이 있습니다',
        409,
      );
    }

    const targetReservations = await this.prisma.reservation.findMany({
      where: {
        programId: sessionId,
        status: { in: ['PENDING', 'CONFIRMED'] },
      },
      include: { payment: true, program: true },
    });

    const mode = this.determineMode();

    if (dryRun) {
    
AdminBulkCancelService.startJob method · typescript · L188-L256 (69 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  async startJob(jobId: string): Promise<StartJobResult> {
    const job = await this.prisma.bulkCancelJob.findUnique({
      where: { id: jobId },
      include: {
        items: {
          include: {
            reservation: { include: { payment: true, program: true } },
          },
        },
      },
    });

    if (!job) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_NOT_FOUND',
        '일괄 취소 작업을 찾을 수 없습니다',
        404,
      );
    }

    if (
      job.status === 'COMPLETED' ||
      job.status === 'COMPLETED_WITH_ERRORS' ||
      job.status === 'FAILED'
    ) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_COMPLETED',
        '이미 완료된 작업입니다',
        409,
      );
    }

    if (job.status === 'RUNNING') {
      return { message: '이미 실행 중입니다', jobId: job.id };
    }

    await this.prisma.bulkCancelJob.update({
      where: { id: jobId },
      data: { status: 'RUNNING', startedAt: new Date() },
    });

    let successCount = 0;
    let failedC
AdminBulkCancelService.processItem method · typescript · L258-L355 (98 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  private async processItem(
    item: ProcessItemInput,
    mode: string,
  ): Promise<BulkCancelItemResult> {
    if (item.result === 'SUCCESS') {
      return 'SKIPPED';
    }

    const reservation = item.reservation;

    if (shouldSkipBulkCancel(reservation)) {
      await this.prisma.bulkCancelJobItem.update({
        where: { id: item.id },
        data: { result: 'SKIPPED', attemptedAt: new Date() },
      });
      return 'SKIPPED';
    }

    const refundAmount = this.calculateRefundAmount(
      reservation.totalPrice,
      reservation.program.scheduleAt,
    );

    try {
      if (
        mode === 'A_PG_REFUND' &&
        reservation.payment &&
        refundAmount > 0 &&
        this.paymentsService
      ) {
        await this.paymentsService.processRefund(reservation.id, refundAmount);
      }

      await this.prisma.$transaction(async (tx) => {
        await tx.reservation.update({
          where: { id: reservation.id },
          data: { status: 'CANCELLED' },
  
Hi, dataset curator — please cite Repobility (https://repobility.com) when reusing this data.
AdminBulkCancelService.calculateRefundAmount method · typescript · L357-L375 (19 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  private calculateRefundAmount(
    totalPrice: number,
    scheduleAt: Date,
  ): number {
    const now = new Date();
    const diffMs = scheduleAt.getTime() - now.getTime();
    const diffDays = diffMs / (1000 * 60 * 60 * 24);

    let refundRatio: number;
    if (diffDays >= 2) {
      refundRatio = REFUND_POLICY.DAYS_BEFORE_2;
    } else if (diffDays >= 1) {
      refundRatio = REFUND_POLICY.DAYS_BEFORE_1;
    } else {
      refundRatio = REFUND_POLICY.SAME_DAY;
    }

    return Math.floor(totalPrice * refundRatio);
  }
AdminBulkCancelService.deriveFinalStatus method · typescript · L377-L385 (9 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  private deriveFinalStatus(
    successCount: number,
    failedCount: number,
    skippedCount: number,
  ): BulkCancelJobStatus {
    if (failedCount === 0) return 'COMPLETED';
    if (successCount > 0 || skippedCount > 0) return 'COMPLETED_WITH_ERRORS';
    return 'FAILED';
  }
AdminBulkCancelService.getJobSummary method · typescript · L387-L404 (18 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  async getJobSummary(jobId: string) {
    const job = await this.prisma.bulkCancelJob.findUnique({
      where: { id: jobId },
      include: {
        program: { select: { id: true, title: true, scheduleAt: true } },
      },
    });

    if (!job) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_NOT_FOUND',
        '일괄 취소 작업을 찾을 수 없습니다',
        404,
      );
    }

    return job;
  }
AdminBulkCancelService.getJobItems method · typescript · L406-L455 (50 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  async getJobItems(
    jobId: string,
    page = 1,
    pageSize = 20,
    result?: BulkCancelItemResult,
  ) {
    const job = await this.prisma.bulkCancelJob.findUnique({
      where: { id: jobId },
    });
    if (!job) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_NOT_FOUND',
        '일괄 취소 작업을 찾을 수 없습니다',
        404,
      );
    }

    const where: { jobId: string; result?: BulkCancelItemResult } = { jobId };
    if (result) {
      where.result = result;
    }

    const [items, total] = await Promise.all([
      this.prisma.bulkCancelJobItem.findMany({
        where,
        include: {
          reservation: {
            select: {
              id: true,
              userId: true,
              totalPrice: true,
              status: true,
            },
          },
        },
        skip: (page - 1) * pageSize,
        take: pageSize,
        orderBy: { attemptedAt: 'desc' },
      }),
      this.prisma.bulkCancelJobItem.count({ where }),
    ]);

    re
AdminBulkCancelService.retryFailed method · typescript · L457-L513 (57 LOC)
apps/server/src/admin/admin-bulk-cancel.service.ts
  async retryFailed(jobId: string): Promise<RetryFailedResult> {
    const job = await this.prisma.bulkCancelJob.findUnique({
      where: { id: jobId },
      include: {
        items: {
          where: { result: 'FAILED' },
          include: {
            reservation: { include: { payment: true, program: true } },
          },
        },
      },
    });

    if (!job) {
      throw new BusinessException(
        'BULK_CANCEL_JOB_NOT_FOUND',
        '일괄 취소 작업을 찾을 수 없습니다',
        404,
      );
    }

    if (job.items.length === 0) {
      return { message: '재시도할 실패 항목이 없습니다', jobId: job.id };
    }

    await this.prisma.bulkCancelJob.update({
      where: { id: jobId },
      data: { status: 'RUNNING', startedAt: new Date(), finishedAt: null },
    });

    let successCount = job.successCount;
    let failedCount = 0;
    const skippedCount = job.skippedCount;

    for (const item of job.items) {
      const result = await this.processItem(item, job.mode);
      if (result === 'S
AdminController.constructor method · typescript · L53-L58 (6 LOC)
apps/server/src/admin/admin.controller.ts
  constructor(
    private adminService: AdminService,
    private settlementsService: SettlementsService,
    private bulkCancelService: AdminBulkCancelService,
    private categoriesService: CategoriesService,
  ) {}
AdminController.getBulkCancelJobItems method · typescript · L287-L297 (11 LOC)
apps/server/src/admin/admin.controller.ts
  getBulkCancelJobItems(
    @Param('jobId') jobId: string,
    @Query() query: QueryBulkCancelItemsDto,
  ) {
    return this.bulkCancelService.getJobItems(
      jobId,
      query.page,
      query.pageSize,
      query.result,
    );
  }
AdminService.getDashboardStats method · typescript · L37-L63 (27 LOC)
apps/server/src/admin/admin.service.ts
  async getDashboardStats() {
    const [totalUsers, totalReservations, totalRevenue, pendingPrograms, pendingInstructors] =
      await Promise.all([
        this.prisma.user.count(),
        this.prisma.reservation.count({
          where: { status: { in: ['CONFIRMED', 'COMPLETED'] } },
        }),
        this.prisma.payment.aggregate({
          where: { status: 'PAID' },
          _sum: { amount: true },
        }),
        this.prisma.program.count({
          where: { approvalStatus: 'PENDING_REVIEW' },
        }),
        this.prisma.user.count({
          where: { instructorStatus: 'APPLIED' },
        }),
      ]);

    return {
      totalUsers,
      totalReservations,
      totalRevenue: totalRevenue._sum.amount ?? 0,
      pendingPrograms,
      pendingInstructors,
    };
  }
All rows above produced by Repobility · https://repobility.com
AdminService.findPrograms method · typescript · L65-L96 (32 LOC)
apps/server/src/admin/admin.service.ts
  async findPrograms(query: AdminQueryProgramsDto) {
    const where: Prisma.ProgramWhereInput = {};

    if (query.approvalStatus) {
      where.approvalStatus = query.approvalStatus;
    }

    if (query.search) {
      where.OR = [
        { title: { contains: query.search, mode: 'insensitive' } },
        { instructor: { name: { contains: query.search, mode: 'insensitive' } } },
      ];
    }

    const page = query.page ?? 1;
    const limit = query.limit ?? 20;

    const [items, total] = await Promise.all([
      this.prisma.program.findMany({
        where,
        include: {
          instructor: { select: { id: true, name: true, email: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.program.count({ where }),
    ]);

    return { items, total, page, limit };
  }
AdminService.approveProgram method · typescript · L98-L126 (29 LOC)
apps/server/src/admin/admin.service.ts
  async approveProgram(id: string) {
    const program = await this.prisma.program.findUnique({
      where: { id },
      select: { id: true, title: true, instructorId: true, approvalStatus: true },
    });

    if (!program) {
      throw new NotFoundException('프로그램을 찾을 수 없습니다');
    }

    if (program.approvalStatus !== 'PENDING_REVIEW') {
      throw new BadRequestException('검수 대기 상태의 프로그램만 승인할 수 있습니다');
    }

    const updated = await this.prisma.program.update({
      where: { id },
      data: { approvalStatus: 'APPROVED', rejectionReason: null },
    });

    await this.notificationsService.createAndSend(
      program.instructorId,
      'PROGRAM_APPROVED',
      '프로그램 승인 알림',
      `"${program.title}" 프로그램이 승인되었습니다. 이제 부모님들에게 노출됩니다.`,
      { programId: program.id },
    );

    return updated;
  }
AdminService.rejectProgram method · typescript · L128-L159 (32 LOC)
apps/server/src/admin/admin.service.ts
  async rejectProgram(id: string, dto: RejectProgramDto) {
    const program = await this.prisma.program.findUnique({
      where: { id },
      select: { id: true, title: true, instructorId: true, approvalStatus: true },
    });

    if (!program) {
      throw new NotFoundException('프로그램을 찾을 수 없습니다');
    }

    if (program.approvalStatus !== 'PENDING_REVIEW') {
      throw new BadRequestException('검수 대기 상태의 프로그램만 거절할 수 있습니다');
    }

    const updated = await this.prisma.program.update({
      where: { id },
      data: {
        approvalStatus: 'REJECTED',
        rejectionReason: dto.rejectionReason,
      },
    });

    await this.notificationsService.createAndSend(
      program.instructorId,
      'PROGRAM_REJECTED',
      '프로그램 거절 알림',
      `"${program.title}" 프로그램이 거절되었습니다. 사유: ${dto.rejectionReason}`,
      { programId: program.id },
    );

    return updated;
  }
AdminService.findUsers method · typescript · L161-L198 (38 LOC)
apps/server/src/admin/admin.service.ts
  async findUsers(query: AdminQueryUsersDto) {
    const where: Prisma.UserWhereInput = {};

    if (query.role) {
      where.role = query.role;
    }

    if (query.search) {
      where.OR = [
        { name: { contains: query.search, mode: 'insensitive' } },
        { email: { contains: query.search, mode: 'insensitive' } },
      ];
    }

    const page = query.page ?? 1;
    const limit = query.limit ?? 20;

    const [items, total] = await Promise.all([
      this.prisma.user.findMany({
        where,
        select: {
          id: true,
          email: true,
          name: true,
          role: true,
          phoneNumber: true,
          profileImageUrl: true,
          createdAt: true,
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.user.count({ where }),
    ]);

    return { items, total, page, limit };
  }
AdminService.changeUserRole method · typescript · L200-L217 (18 LOC)
apps/server/src/admin/admin.service.ts
  async changeUserRole(id: string, dto: ChangeRoleDto) {
    const user = await this.prisma.user.findUnique({ where: { id } });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다');
    }

    return this.prisma.user.update({
      where: { id },
      data: { role: dto.role },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
      },
    });
  }
AdminService.findInstructorApplications method · typescript · L219-L261 (43 LOC)
apps/server/src/admin/admin.service.ts
  async findInstructorApplications(query: AdminQueryInstructorsDto) {
    const where: Prisma.UserWhereInput = {
      role: 'INSTRUCTOR',
    };

    if (query.instructorStatus) {
      where.instructorStatus = query.instructorStatus;
    }

    if (query.search) {
      where.OR = [
        { name: { contains: query.search, mode: 'insensitive' } },
        { email: { contains: query.search, mode: 'insensitive' } },
      ];
    }

    const page = query.page ?? 1;
    const limit = query.limit ?? 20;

    const [items, total] = await Promise.all([
      this.prisma.user.findMany({
        where,
        select: {
          id: true,
          email: true,
          name: true,
          role: true,
          phoneNumber: true,
          profileImageUrl: true,
          instructorStatus: true,
          instructorStatusReason: true,
          certifications: true,
          createdAt: true,
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        ta
AdminService.findInstructorById method · typescript · L263-L285 (23 LOC)
apps/server/src/admin/admin.service.ts
  async findInstructorById(userId: string) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        phoneNumber: true,
        profileImageUrl: true,
        instructorStatus: true,
        instructorStatusReason: true,
        certifications: true,
        createdAt: true,
      },
    });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다');
    }

    return user;
  }
AdminService.approveInstructor method · typescript · L287-L317 (31 LOC)
apps/server/src/admin/admin.service.ts
  async approveInstructor(userId: string) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, name: true, role: true, instructorStatus: true },
    });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다');
    }

    if (user.instructorStatus !== 'APPLIED') {
      throw new BadRequestException('신청 대기 상태의 강사만 승인할 수 있습니다');
    }

    const updated = await this.prisma.user.update({
      where: { id: userId },
      data: {
        instructorStatus: 'APPROVED',
        instructorStatusReason: null,
      },
    });

    await this.notificationsService.createAndSend(
      userId,
      'INSTRUCTOR_APPROVED',
      '강사 승인 알림',
      `${user.name}님의 강사 신청이 승인되었습니다. 이제 프로그램을 등록할 수 있습니다.`,
    );

    return updated;
  }
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
AdminService.rejectInstructor method · typescript · L319-L349 (31 LOC)
apps/server/src/admin/admin.service.ts
  async rejectInstructor(userId: string, dto: RejectInstructorDto) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, name: true, role: true, instructorStatus: true },
    });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다');
    }

    if (user.instructorStatus !== 'APPLIED') {
      throw new BadRequestException('신청 대기 상태의 강사만 거절할 수 있습니다');
    }

    const updated = await this.prisma.user.update({
      where: { id: userId },
      data: {
        instructorStatus: 'REJECTED',
        instructorStatusReason: dto.reason,
      },
    });

    await this.notificationsService.createAndSend(
      userId,
      'INSTRUCTOR_REJECTED',
      '강사 신청 거절 알림',
      `${user.name}님의 강사 신청이 거절되었습니다. 사유: ${dto.reason}`,
    );

    return updated;
  }
AdminService.updateInstructorCertifications method · typescript · L351-L374 (24 LOC)
apps/server/src/admin/admin.service.ts
  async updateInstructorCertifications(userId: string, dto: UpdateCertificationsDto) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, role: true },
    });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다');
    }

    if (user.role !== 'INSTRUCTOR') {
      throw new BadRequestException('강사만 인증 뱃지를 설정할 수 있습니다');
    }

    return this.prisma.user.update({
      where: { id: userId },
      data: { certifications: dto.certifications as any },
      select: {
        id: true,
        name: true,
        certifications: true,
      },
    });
  }
AdminService.chargeCash method · typescript · L376-L397 (22 LOC)
apps/server/src/admin/admin.service.ts
  async chargeCash(userId: string, dto: ChargeCashDto) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다');
    }

    const updated = await this.prisma.user.update({
      where: { id: userId },
      data: {
        messageCashBalance: { increment: dto.amount },
      },
      select: {
        id: true,
        email: true,
        name: true,
        messageCashBalance: true,
      },
    });

    return updated;
  }
AdminService.createProvider method · typescript · L401-L408 (8 LOC)
apps/server/src/admin/admin.service.ts
  async createProvider(dto: CreateProviderDto) {
    return this.prisma.provider.create({
      data: {
        name: dto.name,
        regionTags: dto.regionTags ?? [],
      },
    });
  }
AdminService.findProviders method · typescript · L410-L434 (25 LOC)
apps/server/src/admin/admin.service.ts
  async findProviders(query: AdminQueryProvidersDto) {
    const where: Prisma.ProviderWhereInput = {};

    if (query.query) {
      where.name = { contains: query.query, mode: 'insensitive' };
    }

    const page = query.page ?? 1;
    const pageSize = query.pageSize ?? 20;

    const [items, total] = await Promise.all([
      this.prisma.provider.findMany({
        where,
        include: {
          profile: { select: { isPublished: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * pageSize,
        take: pageSize,
      }),
      this.prisma.provider.count({ where }),
    ]);

    return { items, total, page, pageSize };
  }
AdminService.updateProvider method · typescript · L436-L447 (12 LOC)
apps/server/src/admin/admin.service.ts
  async updateProvider(id: string, dto: UpdateProviderDto) {
    const provider = await this.prisma.provider.findUnique({ where: { id } });
    if (!provider) {
      throw new NotFoundException('업체를 찾을 수 없습니다');
    }

    const data: Prisma.ProviderUpdateInput = {};
    if (dto.name !== undefined) data.name = dto.name;
    if (dto.regionTags !== undefined) data.regionTags = dto.regionTags;

    return this.prisma.provider.update({ where: { id }, data });
  }
AdminService.getProviderProfile method · typescript · L449-L495 (47 LOC)
apps/server/src/admin/admin.service.ts
  async getProviderProfile(providerId: string) {
    const provider = await this.prisma.provider.findUnique({
      where: { id: providerId },
    });
    if (!provider) {
      throw new NotFoundException('업체를 찾을 수 없습니다');
    }

    const profile = await this.prisma.providerProfile.findUnique({
      where: { providerId },
    });

    if (!profile) {
      return {
        provider: { id: provider.id, name: provider.name, regionTags: provider.regionTags },
        profile: {
          displayName: provider.name,
          introShort: null,
          certificationsText: null,
          storyText: null,
          coverImageUrls: [],
          contactLinks: [],
          isPublished: false,
        },
      };
    }

    const coverImageKeys = profile.coverImageUrls as string[];
    const coverImageUrls = await Promise.all(
      coverImageKeys.map((key) =>
        this.storageService.generateDownloadUrl(key, GALLERY_SIGNED_URL_EXPIRES_IN),
      ),
    );

    return {
      provider:
AdminService.upsertProviderProfile method · typescript · L497-L519 (23 LOC)
apps/server/src/admin/admin.service.ts
  async upsertProviderProfile(providerId: string, dto: AdminUpsertProfileDto) {
    const provider = await this.prisma.provider.findUnique({
      where: { id: providerId },
    });
    if (!provider) {
      throw new NotFoundException('업체를 찾을 수 없습니다');
    }

    const data = {
      displayName: dto.displayName,
      introShort: dto.introShort ?? null,
      certificationsText: dto.certificationsText ?? null,
      storyText: dto.storyText ?? null,
      coverImageUrls: dto.coverImageUrls ?? [],
      contactLinks: dto.contactLinks ?? [],
    };

    return this.prisma.providerProfile.upsert({
      where: { providerId },
      create: { providerId, ...data },
      update: data,
    });
  }
Generated by Repobility's multi-pass static-analysis pipeline (https://repobility.com)
AdminService.presignProviderCoverImages method · typescript · L521-L548 (28 LOC)
apps/server/src/admin/admin.service.ts
  async presignProviderCoverImages(providerId: string, dto: PresignCoverDto) {
    const provider = await this.prisma.provider.findUnique({
      where: { id: providerId },
    });
    if (!provider) {
      throw new NotFoundException('업체를 찾을 수 없습니다');
    }

    const uploads = await Promise.all(
      dto.files.map(async (file) => {
        const ext = path.extname(file.filename);
        const key = `provider-covers/${providerId}/${randomUUID()}${ext}`;
        const uploadUrl = await this.storageService.generateUploadUrl(
          key,
          file.contentType,
          PROVIDER_COVER_UPLOAD_URL_EXPIRES_IN,
        );
        return {
          uploadUrl,
          method: 'PUT',
          headers: { 'Content-Type': file.contentType },
          finalUrl: key,
        };
      }),
    );

    return { uploads };
  }
AdminService.publishProviderProfile method · typescript · L550-L562 (13 LOC)
apps/server/src/admin/admin.service.ts
  async publishProviderProfile(providerId: string, dto: PublishProfileDto) {
    const profile = await this.prisma.providerProfile.findUnique({
      where: { providerId },
    });
    if (!profile) {
      throw new NotFoundException('프로필을 먼저 생성해주세요');
    }

    return this.prisma.providerProfile.update({
      where: { providerId },
      data: { isPublished: dto.isPublished },
    });
  }
AdminService.findReviews method · typescript · L566-L595 (30 LOC)
apps/server/src/admin/admin.service.ts
  async findReviews(query: AdminQueryReviewsDto) {
    const where: Prisma.ReviewWhereInput = {};

    if (query.status) {
      where.status = query.status;
    }

    if (query.rating) {
      where.rating = query.rating;
    }

    const page = query.page ?? 1;
    const limit = query.limit ?? 20;

    const [items, total] = await Promise.all([
      this.prisma.review.findMany({
        where,
        include: {
          program: { select: { id: true, title: true } },
          parentUser: { select: { id: true, name: true, email: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.review.count({ where }),
    ]);

    return { items, total, page, limit };
  }
page 1 / 4next ›