opensoma 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/client.d.ts +3 -3
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +37 -5
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +1 -1
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +94 -52
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/mentoring.d.ts.map +1 -1
  11. package/dist/src/commands/mentoring.js +26 -20
  12. package/dist/src/commands/mentoring.js.map +1 -1
  13. package/dist/src/commands/room.d.ts.map +1 -1
  14. package/dist/src/commands/room.js +25 -2
  15. package/dist/src/commands/room.js.map +1 -1
  16. package/dist/src/constants.d.ts +52 -9
  17. package/dist/src/constants.d.ts.map +1 -1
  18. package/dist/src/constants.js +65 -9
  19. package/dist/src/constants.js.map +1 -1
  20. package/dist/src/formatters.d.ts.map +1 -1
  21. package/dist/src/formatters.js +79 -39
  22. package/dist/src/formatters.js.map +1 -1
  23. package/dist/src/http.d.ts +3 -0
  24. package/dist/src/http.d.ts.map +1 -1
  25. package/dist/src/http.js +42 -1
  26. package/dist/src/http.js.map +1 -1
  27. package/dist/src/index.d.ts +3 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +2 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/shared/utils/html.d.ts +3 -0
  32. package/dist/src/shared/utils/html.d.ts.map +1 -0
  33. package/dist/src/shared/utils/html.js +12 -0
  34. package/dist/src/shared/utils/html.js.map +1 -0
  35. package/dist/src/shared/utils/swmaestro.d.ts +2 -0
  36. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  37. package/dist/src/shared/utils/swmaestro.js +28 -5
  38. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  39. package/dist/src/shared/utils/toz.d.ts +23 -0
  40. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  41. package/dist/src/shared/utils/toz.js +72 -0
  42. package/dist/src/shared/utils/toz.js.map +1 -0
  43. package/dist/src/token-extractor.d.ts +9 -1
  44. package/dist/src/token-extractor.d.ts.map +1 -1
  45. package/dist/src/token-extractor.js +54 -10
  46. package/dist/src/token-extractor.js.map +1 -1
  47. package/dist/src/toz-formatters.d.ts +9 -0
  48. package/dist/src/toz-formatters.d.ts.map +1 -0
  49. package/dist/src/toz-formatters.js +151 -0
  50. package/dist/src/toz-formatters.js.map +1 -0
  51. package/dist/src/toz-http.d.ts +27 -0
  52. package/dist/src/toz-http.d.ts.map +1 -0
  53. package/dist/src/toz-http.js +154 -0
  54. package/dist/src/toz-http.js.map +1 -0
  55. package/dist/src/types.d.ts +88 -0
  56. package/dist/src/types.d.ts.map +1 -1
  57. package/dist/src/types.js +65 -1
  58. package/dist/src/types.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  61. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  62. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  63. package/src/__fixtures__/toz/toz_duration.json +25 -0
  64. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  65. package/src/__fixtures__/toz/toz_page.html +211 -0
  66. package/src/client.test.ts +63 -16
  67. package/src/client.ts +43 -6
  68. package/src/commands/auth.ts +107 -50
  69. package/src/commands/mentoring.ts +34 -26
  70. package/src/commands/room.ts +31 -3
  71. package/src/constants.ts +74 -9
  72. package/src/formatters.test.ts +6 -2
  73. package/src/formatters.ts +92 -45
  74. package/src/http.test.ts +215 -0
  75. package/src/http.ts +45 -1
  76. package/src/index.ts +3 -0
  77. package/src/shared/utils/html.ts +12 -0
  78. package/src/shared/utils/swmaestro.test.ts +44 -0
  79. package/src/shared/utils/swmaestro.ts +30 -5
  80. package/src/shared/utils/toz.test.ts +133 -0
  81. package/src/shared/utils/toz.ts +100 -0
  82. package/src/token-extractor.test.ts +30 -5
  83. package/src/token-extractor.ts +65 -13
  84. package/src/toz-formatters.test.ts +197 -0
  85. package/src/toz-formatters.ts +211 -0
  86. package/src/toz-http.test.ts +157 -0
  87. package/src/toz-http.ts +188 -0
  88. package/src/types.test.ts +4 -1
  89. package/src/types.ts +81 -1
@@ -0,0 +1,211 @@
1
+ import { parse } from 'node-html-parser'
2
+
3
+ import { decodeHtmlEntities } from './shared/utils/html'
4
+ import { parseDurationKey } from './shared/utils/toz'
5
+ import {
6
+ type TozBooth,
7
+ TozBoothSchema,
8
+ type TozBranch,
9
+ TozBranchSchema,
10
+ type TozDuration,
11
+ TozDurationSchema,
12
+ type TozMeeting,
13
+ TozMeetingSchema,
14
+ type TozReservation,
15
+ TozReservationSchema,
16
+ type TozReserved,
17
+ TozReservedSchema,
18
+ } from './types'
19
+
20
+ interface RawBooth {
21
+ resultMsg?: string
22
+ id?: number
23
+ name?: string
24
+ branchName?: string
25
+ branchTel?: string
26
+ minUseUserCount?: number
27
+ enableMaxUserCount?: number
28
+ boothGroupName?: string
29
+ boothGroupUrl?: string | null
30
+ boothMemoForUser?: string
31
+ isLargeBooth?: boolean
32
+ }
33
+
34
+ interface RawReserved {
35
+ result?: string
36
+ resultMsg?: string
37
+ branchName?: string
38
+ branchTel?: string
39
+ boothGroupName?: string
40
+ boothIsLarge?: boolean
41
+ }
42
+
43
+ interface RawDuration {
44
+ key: string
45
+ value: string
46
+ }
47
+
48
+ export function parseBookingPageBranches(html: string): TozBranch[] {
49
+ const root = parse(html)
50
+ const inputs = root.querySelectorAll('input[name="branch_id"]')
51
+ const branches: TozBranch[] = []
52
+
53
+ for (const input of inputs) {
54
+ const value = input.getAttribute('value')
55
+ if (!value) continue
56
+ const id = Number.parseInt(value, 10)
57
+ if (!Number.isFinite(id)) continue
58
+
59
+ const label = input.parentNode
60
+ const name = label ? cleanText(label.text) : ''
61
+ if (!name) continue
62
+
63
+ branches.push(TozBranchSchema.parse({ id, name }))
64
+ }
65
+
66
+ return branches
67
+ }
68
+
69
+ export function parseBookingPageMeetings(html: string): TozMeeting[] {
70
+ const root = parse(html)
71
+ const select = root.querySelector('select[name="meeting_id"]')
72
+ if (!select) return []
73
+
74
+ const meetings: TozMeeting[] = []
75
+ for (const option of select.querySelectorAll('option')) {
76
+ const value = option.getAttribute('value')
77
+ if (!value || value === '새모임') continue
78
+ const id = Number.parseInt(value, 10)
79
+ if (!Number.isFinite(id)) continue
80
+
81
+ const name = cleanText(option.text)
82
+ if (!name) continue
83
+
84
+ meetings.push(TozMeetingSchema.parse({ id, name }))
85
+ }
86
+
87
+ return meetings
88
+ }
89
+
90
+ export function parseTozDurations(json: unknown): TozDuration[] {
91
+ if (!Array.isArray(json)) return []
92
+ const seen = new Set<string>()
93
+ const durations: TozDuration[] = []
94
+
95
+ for (const raw of json as RawDuration[]) {
96
+ if (!raw?.key || !raw?.value || seen.has(raw.key)) continue
97
+ seen.add(raw.key)
98
+ durations.push(
99
+ TozDurationSchema.parse({
100
+ key: raw.key,
101
+ value: raw.value,
102
+ minutes: parseDurationKey(raw.key),
103
+ }),
104
+ )
105
+ }
106
+
107
+ return durations
108
+ }
109
+
110
+ export function parseTozBoothes(json: unknown): TozBooth[] {
111
+ if (!Array.isArray(json)) return []
112
+ const items = json as RawBooth[]
113
+ if (items.length === 0) return []
114
+
115
+ const first = items[0]
116
+ if (first?.resultMsg && first.resultMsg !== 'SUCCESS') {
117
+ throw new Error(first.resultMsg)
118
+ }
119
+
120
+ return items
121
+ .filter((raw) => raw.id !== undefined && raw.id !== null)
122
+ .map((raw) =>
123
+ TozBoothSchema.parse({
124
+ id: raw.id,
125
+ name: raw.name ?? '',
126
+ branchName: raw.branchName ?? '',
127
+ branchTel: raw.branchTel ?? '',
128
+ minUseUserCount: raw.minUseUserCount ?? 0,
129
+ enableMaxUserCount: raw.enableMaxUserCount ?? 0,
130
+ boothGroupName: raw.boothGroupName ?? '',
131
+ boothGroupUrl: raw.boothGroupUrl ?? null,
132
+ boothMemoForUser: raw.boothMemoForUser ?? '',
133
+ isLargeBooth: Boolean(raw.isLargeBooth),
134
+ }),
135
+ )
136
+ }
137
+
138
+ export function parseTozReserved(json: unknown): TozReserved {
139
+ const raw = json as RawReserved | null
140
+ if (!raw || raw.result !== 'SUCCESS' || !raw.resultMsg) {
141
+ throw new Error(raw?.resultMsg || '부스 예약에 실패했습니다.')
142
+ }
143
+
144
+ return TozReservedSchema.parse({
145
+ reservationId: raw.resultMsg,
146
+ branchName: raw.branchName ?? '',
147
+ branchTel: raw.branchTel ?? '',
148
+ boothGroupName: raw.boothGroupName ?? '',
149
+ isLargeBooth: Boolean(raw.boothIsLarge),
150
+ })
151
+ }
152
+
153
+ const SUCCESS_PATTERNS = ['예약 되었습니다', '대형부스 예약 신청되었습니다']
154
+
155
+ export function isReservationConfirmSuccess(resultMsg: string): boolean {
156
+ return SUCCESS_PATTERNS.some((p) => resultMsg.includes(p))
157
+ }
158
+
159
+ export function parseMypageReservations(html: string): TozReservation[] {
160
+ const root = parse(html)
161
+ // The 실시간예약 table is the second table.reservation in the page.
162
+ // The 상담요청 table (first) lives inside a parent <table style="display:none">.
163
+ const tables = root.querySelectorAll('table.reservation')
164
+ if (tables.length === 0) return []
165
+
166
+ const target = tables[tables.length - 1]
167
+ const rows = target.querySelectorAll('tbody tr')
168
+ const reservations: TozReservation[] = []
169
+
170
+ for (const row of rows) {
171
+ const cells = row.querySelectorAll('td')
172
+ if (cells.length !== 8) continue
173
+
174
+ const [no, meetingName, date, time, branch, booth, reservedAt, statusCell] = cells
175
+ const cancelLink = statusCell.querySelector('a')
176
+ const reservationId = extractReservationIdFromOnclick(cancelLink?.getAttribute('href'))
177
+
178
+ reservations.push(
179
+ TozReservationSchema.parse({
180
+ no: Number.parseInt(cleanText(no.text), 10) || 0,
181
+ reservationId,
182
+ meetingName: cleanText(meetingName.text),
183
+ date: cleanText(date.text),
184
+ ...splitTimeRange(cleanText(time.text)),
185
+ branchName: cleanText(branch.text),
186
+ boothName: cleanText(booth.text),
187
+ reservedAt: cleanText(reservedAt.text),
188
+ status: cleanText(statusCell.text),
189
+ }),
190
+ )
191
+ }
192
+
193
+ return reservations
194
+ }
195
+
196
+ function splitTimeRange(text: string): { startTime: string; endTime: string } {
197
+ const match = /(\d{1,2}:\d{2})\s*[~-]\s*(\d{1,2}:\d{2})/.exec(text)
198
+ if (!match) return { startTime: text, endTime: '' }
199
+ return { startTime: match[1], endTime: match[2] }
200
+ }
201
+
202
+ function extractReservationIdFromOnclick(href: string | undefined): number | null {
203
+ if (!href) return null
204
+ const match = /destroyReservation\(\s*(\d+)\s*\)/.exec(href)
205
+ if (!match) return null
206
+ return Number.parseInt(match[1], 10)
207
+ }
208
+
209
+ function cleanText(text: string): string {
210
+ return decodeHtmlEntities(text).replace(/\s+/g, ' ').trim()
211
+ }
@@ -0,0 +1,157 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ import { TOZ_BASE_URL } from './constants'
4
+ import { TozHttp } from './toz-http'
5
+
6
+ const originalFetch = globalThis.fetch
7
+
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch
10
+ mock.restore()
11
+ })
12
+
13
+ describe('TozHttp', () => {
14
+ test('bootstrap GETs index.htm to seed JSESSIONID', async () => {
15
+ const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
16
+ expect(String(input)).toBe(`${TOZ_BASE_URL}/index.htm`)
17
+ expect(init?.method).toBe('GET')
18
+ return createResponse('ok', ['JSESSIONID=ABC123; Path=/; HttpOnly'])
19
+ })
20
+ globalThis.fetch = fetchMock as typeof fetch
21
+
22
+ const http = new TozHttp()
23
+ await http.bootstrap()
24
+
25
+ expect(http.getSessionCookie()).toBe('ABC123')
26
+ expect(fetchMock).toHaveBeenCalledTimes(1)
27
+ })
28
+
29
+ test('bootstrap is no-op when JSESSIONID already set', async () => {
30
+ const fetchMock = mock(async () => createResponse('ok'))
31
+ globalThis.fetch = fetchMock as typeof fetch
32
+
33
+ const http = new TozHttp({ sessionCookie: 'JSESSIONID=EXISTING' })
34
+ await http.bootstrap()
35
+
36
+ expect(fetchMock).not.toHaveBeenCalled()
37
+ expect(http.getSessionCookie()).toBe('EXISTING')
38
+ })
39
+
40
+ test('post sends URL-encoded body with required headers and cookie', async () => {
41
+ const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
42
+ expect(String(input)).toBe(`${TOZ_BASE_URL}/ajaxGetEnableBoothes.htm`)
43
+ expect(init?.method).toBe('POST')
44
+
45
+ const headers = init?.headers as Record<string, string>
46
+ expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded; charset=UTF-8')
47
+ expect(headers['X-Requested-With']).toBe('XMLHttpRequest')
48
+ expect(headers.Referer).toBe(`${TOZ_BASE_URL}/booking.htm`)
49
+ expect(headers.cookie).toBe('JSESSIONID=ABC123')
50
+
51
+ expect(init?.body).toBe('basedate=2026-04-21&starttime=1400&durationTime=0200&userCount=2&branchIds=27%2C')
52
+ return createResponse('[]', [], 'application/json')
53
+ })
54
+ globalThis.fetch = fetchMock as typeof fetch
55
+
56
+ const http = new TozHttp({ sessionCookie: 'ABC123' })
57
+ const text = await http.post('/ajaxGetEnableBoothes.htm', {
58
+ basedate: '2026-04-21',
59
+ starttime: '1400',
60
+ durationTime: '0200',
61
+ userCount: '2',
62
+ branchIds: '27,',
63
+ })
64
+
65
+ expect(text).toBe('[]')
66
+ })
67
+
68
+ test('postJson parses JSON response', async () => {
69
+ globalThis.fetch = mock(async () => createResponse('{"result":"SUCCESS","resultMsg":"abc"}')) as typeof fetch
70
+
71
+ const http = new TozHttp({ sessionCookie: 'ABC' })
72
+ const json = await http.postJson<{ result: string; resultMsg: string }>('/ajaxReservationBooth.htm', {})
73
+
74
+ expect(json).toEqual({ result: 'SUCCESS', resultMsg: 'abc' })
75
+ })
76
+
77
+ test('postText trims plain-text response', async () => {
78
+ globalThis.fetch = mock(async () => createResponse('SUCCESS\n')) as typeof fetch
79
+
80
+ const http = new TozHttp({ sessionCookie: 'ABC' })
81
+ expect(await http.postText('/ajaxHpVerify.htm', {})).toBe('SUCCESS')
82
+ })
83
+
84
+ test('strips JSESSIONID= prefix when constructed with full cookie string', async () => {
85
+ const http = new TozHttp({ sessionCookie: 'JSESSIONID=XYZ789' })
86
+ expect(http.getSessionCookie()).toBe('XYZ789')
87
+ expect(http.getCookies()).toEqual({ JSESSIONID: 'XYZ789' })
88
+ })
89
+
90
+ test('constructor with cookies map preserves all cookies', async () => {
91
+ const http = new TozHttp({ cookies: { JSESSIONID: 'A', other: 'B' } })
92
+ expect(http.getCookies()).toEqual({ JSESSIONID: 'A', other: 'B' })
93
+ })
94
+
95
+ test('throws on non-2xx response', async () => {
96
+ globalThis.fetch = mock(async () =>
97
+ createResponse('Server Error', [], 'text/html', { status: 500 }),
98
+ ) as typeof fetch
99
+
100
+ const http = new TozHttp({ sessionCookie: 'ABC' })
101
+ await expect(http.get('/index.htm')).rejects.toThrow(/HTTP 500/)
102
+ })
103
+
104
+ test('refuses to follow cross-origin redirects (no cookie leak)', async () => {
105
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
106
+ const url = String(input)
107
+ if (url.endsWith('/index.htm')) {
108
+ return redirectResponse('https://evil.example.com/steal')
109
+ }
110
+ throw new Error(`Should not fetch ${url} — cross-origin redirect must be blocked`)
111
+ }) as typeof fetch
112
+
113
+ const http = new TozHttp({ sessionCookie: 'ABC' })
114
+ await expect(http.get('/index.htm')).rejects.toThrow(/cross-origin redirect/)
115
+ })
116
+
117
+ test('follows same-origin redirects normally', async () => {
118
+ let hit = 0
119
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
120
+ const url = String(input)
121
+ hit += 1
122
+ if (hit === 1) return redirectResponse(`${TOZ_BASE_URL}/mypage.htm`)
123
+ expect(url).toBe(`${TOZ_BASE_URL}/mypage.htm`)
124
+ return createResponse('mypage content')
125
+ }) as typeof fetch
126
+
127
+ const http = new TozHttp({ sessionCookie: 'ABC' })
128
+ const body = await http.get('/mypage_login_.htm')
129
+ expect(body).toBe('mypage content')
130
+ expect(hit).toBe(2)
131
+ })
132
+ })
133
+
134
+ function redirectResponse(location: string): Response {
135
+ const headers = new Headers({ location })
136
+ const response = new Response(null, { status: 302, headers })
137
+ Object.defineProperty(response.headers, 'getSetCookie', {
138
+ value: () => [],
139
+ configurable: true,
140
+ })
141
+ return response
142
+ }
143
+
144
+ function createResponse(
145
+ body: string,
146
+ cookies: string[] = [],
147
+ contentType = 'text/html',
148
+ options: { status?: number } = {},
149
+ ): Response {
150
+ const headers = new Headers({ 'Content-Type': contentType })
151
+ const response = new Response(body, { headers, status: options.status ?? 200 })
152
+ Object.defineProperty(response.headers, 'getSetCookie', {
153
+ value: () => cookies,
154
+ configurable: true,
155
+ })
156
+ return response
157
+ }
@@ -0,0 +1,188 @@
1
+ import { TOZ_BASE_URL } from './constants'
2
+
3
+ const DEFAULT_USER_AGENT =
4
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
5
+ const DEFAULT_ACCEPT_LANGUAGE = 'ko,en-US;q=0.9,en;q=0.8'
6
+
7
+ interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
8
+ getSetCookie?: () => string[]
9
+ }
10
+
11
+ export interface TozHttpOptions {
12
+ sessionCookie?: string
13
+ cookies?: Record<string, string>
14
+ }
15
+
16
+ export interface TozHttpState {
17
+ cookies: Record<string, string>
18
+ }
19
+
20
+ export class TozHttp {
21
+ private cookies = new Map<string, string>()
22
+
23
+ constructor(options: TozHttpOptions = {}) {
24
+ if (options.cookies) {
25
+ for (const [name, value] of Object.entries(options.cookies)) {
26
+ this.cookies.set(name, value)
27
+ }
28
+ } else if (options.sessionCookie) {
29
+ this.cookies.set('JSESSIONID', stripJsessionPrefix(options.sessionCookie))
30
+ }
31
+ }
32
+
33
+ async bootstrap(): Promise<void> {
34
+ if (this.cookies.has('JSESSIONID')) return
35
+ await this.get('/index.htm')
36
+ }
37
+
38
+ async get(path: string, params?: Record<string, string>): Promise<string> {
39
+ const response = await fetch(this.buildUrl(path, params), {
40
+ method: 'GET',
41
+ headers: this.buildHeaders(),
42
+ redirect: 'manual',
43
+ })
44
+
45
+ this.updateFromResponse(response)
46
+ return this.readFollowingRedirects(response, 'GET')
47
+ }
48
+
49
+ async post(path: string, body: Record<string, string>): Promise<string> {
50
+ const response = await this.rawPost(path, body)
51
+ return this.readFollowingRedirects(response, 'POST')
52
+ }
53
+
54
+ async postJson<T>(path: string, body: Record<string, string>): Promise<T> {
55
+ const text = await this.post(path, body)
56
+ try {
57
+ return JSON.parse(text) as T
58
+ } catch (error) {
59
+ throw new Error(
60
+ `Failed to parse JSON response from ${path}: ${error instanceof Error ? error.message : 'unknown'}`,
61
+ )
62
+ }
63
+ }
64
+
65
+ async postText(path: string, body: Record<string, string>): Promise<string> {
66
+ return (await this.post(path, body)).trim()
67
+ }
68
+
69
+ getCookies(): Record<string, string> {
70
+ return Object.fromEntries(this.cookies)
71
+ }
72
+
73
+ getSessionCookie(): string | undefined {
74
+ return this.cookies.get('JSESSIONID')
75
+ }
76
+
77
+ getState(): TozHttpState {
78
+ return { cookies: this.getCookies() }
79
+ }
80
+
81
+ private async rawPost(path: string, body: Record<string, string>): Promise<Response> {
82
+ const url = this.buildUrl(path)
83
+ const formBody = new URLSearchParams(body).toString()
84
+
85
+ const response = await fetch(url, {
86
+ method: 'POST',
87
+ headers: {
88
+ ...this.buildHeaders(),
89
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
90
+ 'X-Requested-With': 'XMLHttpRequest',
91
+ Referer: this.buildUrl('/booking.htm'),
92
+ },
93
+ body: formBody,
94
+ redirect: 'manual',
95
+ })
96
+
97
+ this.updateFromResponse(response)
98
+ return response
99
+ }
100
+
101
+ private async readFollowingRedirects(initial: Response, method: string): Promise<string> {
102
+ let response = initial
103
+ let hops = 0
104
+
105
+ while (response.status >= 300 && response.status < 400 && hops < 5) {
106
+ const location = response.headers.get('location')
107
+ if (!location) break
108
+ const redirectUrl = location.startsWith('http') ? location : new URL(location, `${TOZ_BASE_URL}/`).toString()
109
+ if (!isSameOrigin(redirectUrl, TOZ_BASE_URL)) {
110
+ throw new Error(`Refusing to follow cross-origin redirect to ${new URL(redirectUrl).origin}`)
111
+ }
112
+ response = await fetch(redirectUrl, {
113
+ method: 'GET',
114
+ headers: this.buildHeaders(),
115
+ redirect: 'manual',
116
+ })
117
+ this.updateFromResponse(response)
118
+ hops += 1
119
+ }
120
+
121
+ if (!response.ok) {
122
+ throw new Error(`Toz ${method} ${initial.url} failed: HTTP ${response.status} ${response.statusText}`)
123
+ }
124
+
125
+ return response.text()
126
+ }
127
+
128
+ private buildUrl(path: string, params?: Record<string, string>): string {
129
+ const normalized = path.startsWith('/') ? path.slice(1) : path
130
+ const url = new URL(normalized, `${TOZ_BASE_URL}/`)
131
+ if (params) {
132
+ for (const [key, value] of Object.entries(params)) {
133
+ url.searchParams.set(key, value)
134
+ }
135
+ }
136
+ return url.toString()
137
+ }
138
+
139
+ private buildHeaders(): Record<string, string> {
140
+ const cookieHeader = this.serializeCookies()
141
+ return {
142
+ 'Accept-Language': DEFAULT_ACCEPT_LANGUAGE,
143
+ 'User-Agent': DEFAULT_USER_AGENT,
144
+ ...(cookieHeader ? { cookie: cookieHeader } : {}),
145
+ }
146
+ }
147
+
148
+ private updateFromResponse(response: Response): void {
149
+ for (const cookie of readSetCookies(response.headers)) {
150
+ this.setCookie(cookie)
151
+ }
152
+ }
153
+
154
+ private setCookie(cookieHeader: string): void {
155
+ const pair = cookieHeader.split(';')[0]?.trim()
156
+ if (!pair) return
157
+ const idx = pair.indexOf('=')
158
+ if (idx === -1) return
159
+ const name = pair.slice(0, idx).trim()
160
+ const value = pair.slice(idx + 1).trim()
161
+ if (name) this.cookies.set(name, value)
162
+ }
163
+
164
+ private serializeCookies(): string {
165
+ return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
166
+ }
167
+ }
168
+
169
+ function stripJsessionPrefix(cookie: string): string {
170
+ return cookie.includes('=') ? (cookie.split('=', 2)[1] ?? cookie) : cookie
171
+ }
172
+
173
+ function isSameOrigin(url: string, baseUrl: string): boolean {
174
+ try {
175
+ return new URL(url).origin === new URL(baseUrl).origin
176
+ } catch {
177
+ return false
178
+ }
179
+ }
180
+
181
+ function readSetCookies(headers: Headers): string[] {
182
+ const enhanced = headers as HeadersWithCookieHelpers
183
+ if (typeof enhanced.getSetCookie === 'function') {
184
+ return enhanced.getSetCookie()
185
+ }
186
+ const single = headers.get('set-cookie')
187
+ return single ? [single] : []
188
+ }
package/src/types.test.ts CHANGED
@@ -63,7 +63,10 @@ describe('schemas', () => {
63
63
  capacity: 4,
64
64
  availablePeriod: { start: '2026-04-01', end: '2026-12-31' },
65
65
  description: '소회의실 : 4인',
66
- timeSlots: [{ time: '09:00', available: true }],
66
+ timeSlots: [
67
+ { time: '09:00', available: true },
68
+ { time: '09:30', available: false, reservation: { title: '팀 회의', bookedBy: '전수열' } },
69
+ ],
67
70
  }),
68
71
  ).toBeDefined()
69
72
 
package/src/types.ts CHANGED
@@ -2,7 +2,12 @@ import { z } from 'zod/v4'
2
2
 
3
3
  const DateRangeSchema = z.object({ start: z.string(), end: z.string() })
4
4
  const TimeRangeSchema = z.object({ start: z.string(), end: z.string() })
5
- const RoomTimeSlotSchema = z.object({ time: z.string(), available: z.boolean() })
5
+ const RoomTimeSlotReservationSchema = z.object({ title: z.string(), bookedBy: z.string() })
6
+ const RoomTimeSlotSchema = z.object({
7
+ time: z.string(),
8
+ available: z.boolean(),
9
+ reservation: RoomTimeSlotReservationSchema.optional(),
10
+ })
6
11
  const DashboardStatusItemSchema = z.object({
7
12
  title: z.string(),
8
13
  url: z.string(),
@@ -146,9 +151,67 @@ export const CredentialsSchema = z.object({
146
151
  username: z.string().optional(),
147
152
  password: z.string().optional(),
148
153
  loggedInAt: z.string().optional(),
154
+ tozName: z.string().optional(),
155
+ tozPhone: z.string().optional(),
149
156
  })
150
157
  export type Credentials = z.infer<typeof CredentialsSchema>
151
158
 
159
+ export const TozBranchSchema = z.object({
160
+ id: z.number(),
161
+ name: z.string(),
162
+ })
163
+ export type TozBranch = z.infer<typeof TozBranchSchema>
164
+
165
+ export const TozMeetingSchema = z.object({
166
+ id: z.number(),
167
+ name: z.string(),
168
+ })
169
+ export type TozMeeting = z.infer<typeof TozMeetingSchema>
170
+
171
+ export const TozDurationSchema = z.object({
172
+ key: z.string(),
173
+ value: z.string(),
174
+ minutes: z.number(),
175
+ })
176
+ export type TozDuration = z.infer<typeof TozDurationSchema>
177
+
178
+ export const TozBoothSchema = z.object({
179
+ id: z.number(),
180
+ name: z.string(),
181
+ branchName: z.string(),
182
+ branchTel: z.string(),
183
+ minUseUserCount: z.number(),
184
+ enableMaxUserCount: z.number(),
185
+ boothGroupName: z.string(),
186
+ boothGroupUrl: z.string().nullable(),
187
+ boothMemoForUser: z.string(),
188
+ isLargeBooth: z.boolean(),
189
+ })
190
+ export type TozBooth = z.infer<typeof TozBoothSchema>
191
+
192
+ export const TozReservedSchema = z.object({
193
+ reservationId: z.string(),
194
+ branchName: z.string(),
195
+ branchTel: z.string(),
196
+ boothGroupName: z.string(),
197
+ isLargeBooth: z.boolean(),
198
+ })
199
+ export type TozReserved = z.infer<typeof TozReservedSchema>
200
+
201
+ export const TozReservationSchema = z.object({
202
+ no: z.number(),
203
+ reservationId: z.number().nullable(),
204
+ meetingName: z.string(),
205
+ date: z.string(),
206
+ startTime: z.string(),
207
+ endTime: z.string(),
208
+ branchName: z.string(),
209
+ boothName: z.string(),
210
+ reservedAt: z.string(),
211
+ status: z.string(),
212
+ })
213
+ export type TozReservation = z.infer<typeof TozReservationSchema>
214
+
152
215
  export const ReportListItemSchema = z.object({
153
216
  id: z.number(),
154
217
  category: z.string(),
@@ -222,3 +285,20 @@ export const ReportUpdateOptionsSchema = ReportCreateOptionsSchema.partial().ext
222
285
  id: z.number(),
223
286
  })
224
287
  export type ReportUpdateOptions = z.infer<typeof ReportUpdateOptionsSchema>
288
+
289
+ export const MentoringCreateOptionsSchema = z.object({
290
+ title: z.string(),
291
+ type: z.enum(['public', 'lecture']),
292
+ date: z.string(),
293
+ startTime: z.string(),
294
+ endTime: z.string(),
295
+ venue: z.string(),
296
+ maxAttendees: z.number().optional(),
297
+ regStart: z.string().optional(),
298
+ regEnd: z.string().optional(),
299
+ content: z.string().optional(),
300
+ })
301
+ export type MentoringCreateOptions = z.infer<typeof MentoringCreateOptionsSchema>
302
+
303
+ export const MentoringUpdateOptionsSchema = MentoringCreateOptionsSchema.partial()
304
+ export type MentoringUpdateOptions = z.infer<typeof MentoringUpdateOptionsSchema>