-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathusers_controller.rb
More file actions
399 lines (328 loc) · 14.2 KB
/
Copy pathusers_controller.rb
File metadata and controls
399 lines (328 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# frozen_string_literal: true
module Api
class UsersController < ApiController
skip_before_action :authenticate_user_from_token!, only: [ :onboard ]
# POST /api/user/onboard
#
# The extension sends a Google OAuth access token for the user's (personal)
# Google account — the same account they sync calendars to. We verify it with
# Google and key the account to that verified email, so a caller can only ever
# reach the account tied to a Google identity they actually control (no
# domain restriction — personal accounts are the norm). See GoogleTokenVerifier.
def onboard
access_token = params[:google_access_token] || params[:access_token]
preferred_name = params[:preferred_name]
if access_token.blank?
render json: { error: "google_access_token is required" }, status: :bad_request
return
end
verification = GoogleTokenVerifier.verify_access_token(access_token)
unless verification.success?
Rails.logger.warn("Onboard token verification failed: #{verification.error}")
render json: { error: "Invalid Google token" }, status: :unauthorized
return
end
google_email = verification.email
user = find_or_create_onboarding_user(google_email, preferred_name, params[:wit_email])
token = JsonWebTokenService.encode({ user_id: user.id })
render json: { pub_id: user.public_id.delete_prefix("usr_"), jwt: token }, status: :ok
rescue => e
Rails.logger.error("Error in onboarding user: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
render json: { error: "Failed to onboard user" }, status: :internal_server_error
end
# POST /api/user/gcal
def request_g_cal
email = params[:email].to_s.strip
if email.blank?
render json: { error: "email is required" }, status: :bad_request
return
end
if current_user.google_credential_for_email(email).present?
service = GoogleCalendarService.new(current_user)
calendar_id = service.create_or_get_course_calendar
render json: { message: "email already connected", calendar_id: calendar_id }, status: :ok
else
state = GoogleOauthStateService.generate_state(user_id: current_user.id, email: email)
oauth_url = "#{request.base_url}/auth/google_oauth2?state=#{CGI.escape(state)}"
render json: { message: "OAuth required", email: email, oauth_url: oauth_url }, status: :ok
end
rescue => e
Rails.logger.error("Error requesting Google Calendar for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to request Google Calendar" }, status: :internal_server_error
end
# POST /api/user/gcal/add_email
def add_email_to_g_cal
email = params[:email].to_s.strip
if email.blank?
render json: { error: "email is required" }, status: :bad_request
return
end
unless current_user.google_credential
render json: { error: "Complete Google OAuth for at least one email first." }, status: :unprocessable_content
return
end
credential = current_user.oauth_credentials.find_by(email: email, provider: "google")
unless credential
state = GoogleOauthStateService.generate_state(user_id: current_user.id, email: email)
oauth_url = "#{request.base_url}/auth/google_oauth2?state=#{CGI.escape(state)}"
render json: { message: "OAuth required for this email", email: email, oauth_url: oauth_url }, status: :ok
return
end
service = GoogleCalendarService.new(current_user)
calendar_id = service.create_or_get_course_calendar
render json: { message: "Calendar shared with email", calendar_id: calendar_id }, status: :ok
rescue => e
Rails.logger.error("Error adding email to Google Calendar for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to add email to Google Calendar" }, status: :internal_server_error
end
# DELETE /api/user/gcal/remove_email
def remove_email_from_g_cal
email = params[:email].to_s.strip
if email.blank?
render json: { error: "email is required" }, status: :bad_request
return
end
credential = current_user.oauth_credentials.find_by(email: email, provider: "google")
if credential.nil?
render json: { error: "email not found or not associated with Google Calendar" }, status: :not_found
return
end
authorize credential, :destroy?
google_calendar = current_user.google_credential&.google_calendar
if google_calendar.nil?
render json: { error: "No Google Calendar found" }, status: :not_found
return
end
credential.destroy!
render json: { message: "email removed from Google Calendar association" }, status: :ok
rescue => e
Rails.logger.error("Error removing email from Google Calendar for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to remove email from Google Calendar" }, status: :internal_server_error
end
# GET /api/user/id
def get_id
authorize current_user, :show?
render json: { pub_id: current_user.public_id }, status: :ok
end
# GET /api/user/email
def get_email
authorize current_user, :show?
render json: { email: current_user.email }, status: :ok
end
# GET /api/user/ics_url
def get_ics_url
authorize current_user, :show?
render json: { ics_url: current_user.cal_url_with_extension }, status: :ok
end
# GET /api/user/oauth_credentials
def list_oauth_credentials
authorize current_user, :show?
credentials = current_user.oauth_credentials.includes(:google_calendar).map do |c|
{
id: c.public_id,
email: c.email,
provider: c.provider,
has_calendar: c.google_calendar.present?,
calendar_id: c.google_calendar&.google_calendar_id,
created_at: c.created_at,
needs_reauth: c.needs_reauth?,
token_revoked: c.token_revoked?
}
end
render json: { oauth_credentials: credentials }, status: :ok
end
# DELETE /api/user/oauth_credentials/:credential_id
def disconnect_oauth_credential
credential_id = params[:credential_id]
if credential_id.blank?
render json: { error: "credential_id is required" }, status: :bad_request
return
end
credential = find_by_any_id(OauthCredential, credential_id)
credential = nil unless credential&.user_id == current_user.id
if credential.nil?
render json: { error: "OAuth credential not found" }, status: :not_found
return
end
authorize credential, :destroy?
if current_user.oauth_credentials.one?
render json: { error: "Cannot disconnect the last OAuth credential." }, status: :unprocessable_content
return
end
credential.destroy!
render json: { message: "OAuth credential disconnected successfully" }, status: :ok
rescue => e
Rails.logger.error("Error disconnecting OAuth credential for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to disconnect OAuth credential" }, status: :internal_server_error
end
# POST /api/user/is_processed
def is_processed
authorize current_user, :show?
term_uid = params[:term_uid]
if term_uid.blank?
render json: { error: "term_uid is required" }, status: :bad_request
return
end
term = Term.find_by(uid: term_uid)
if term.nil?
render json: { error: "Term not found" }, status: :not_found
return
end
processed = current_user.enrollments.exists?(term_id: term.id)
render json: { processed: processed }, status: :ok
end
# POST /api/user/processed_events
def get_processed_events_by_term
authorize current_user, :show?
term_uid = params[:term_uid]
if term_uid.blank?
render json: { error: "term_uid is required" }, status: :bad_request
return
end
term = Term.find_by(uid: term_uid)
if term.nil?
render json: { error: "Term not found" }, status: :not_found
return
end
enrollments = current_user
.enrollments
.where(term_id: term.id)
.includes(course: [
:faculties,
{ meeting_times: [ :event_preference, { rooms: :building }, { course: :faculties } ] }
])
preference_resolver = PreferenceResolver.new(current_user)
template_renderer = CalendarTemplateRenderer.new
structured_data = enrollments.map do |enrollment|
EnrolledCourseSerializer.new(
enrollment,
term: term,
preference_resolver: preference_resolver,
template_renderer: template_renderer
).as_json
end
render json: {
classes: structured_data,
notifications_disabled: current_user.notifications_disabled?
}, status: :ok
end
# GET /api/user/flag_enabled
def flag_is_enabled
feature_name = params[:flag_name]
if feature_name.blank?
render json: { error: "flag_name is required" }, status: :bad_request
return
end
feature_sym = feature_name.to_sym
unless FlipperFlags::ALL_FLAGS.include?(feature_sym)
render json: { error: "Unknown feature flag", feature_name: feature_name }, status: :not_found
return
end
flipper_key = FlipperFlags::MAP[feature_sym]
if flipper_key.nil?
render json: { error: "Invalid flag mapping", feature_name: feature_name }, status: :unprocessable_content
return
end
feature = Flipper[flipper_key]
render json: { feature_name: feature_name, is_enabled: feature.enabled?(current_user) }, status: :ok
end
# GET /api/user/feature_flags
def feature_flags
authorize current_user, :show?
flags = {}
FlipperFlags::ALL_FLAGS.each do |flag_name|
flipper_key = FlipperFlags::MAP[flag_name]
next if flipper_key.nil?
flags[flag_name] = Flipper[flipper_key].enabled?(current_user)
end
render json: { feature_flags: flags }, status: :ok
end
# GET /api/user/notifications_status
def notifications_status
authorize current_user, :show?
render json: {
notifications_disabled: current_user.notifications_disabled?,
notifications_disabled_until: current_user.notifications_disabled_until
}, status: :ok
end
# POST /api/user/notifications/disable
def disable_notifications
authorize current_user, :update?
duration_provided = params.key?(:duration) && params[:duration].present?
duration_seconds = params[:duration].to_i if duration_provided
if duration_provided
if duration_seconds < 0
render json: { error: "Duration cannot be negative" }, status: :bad_request
return
end
if duration_seconds > 100.years.to_i
render json: { error: "Duration cannot exceed 100 years" }, status: :bad_request
return
end
end
if duration_provided && duration_seconds > 0
current_user.disable_notifications!(duration: duration_seconds.seconds)
else
current_user.disable_notifications!
end
render json: {
message: "Notifications disabled",
notifications_disabled: true,
notifications_disabled_until: current_user.notifications_disabled_until
}, status: :ok
rescue => e
Rails.logger.error("Error disabling notifications for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to disable notifications" }, status: :internal_server_error
end
# POST /api/user/notifications/enable
def enable_notifications
authorize current_user, :update?
current_user.enable_notifications!
current_user.update_column(:calendar_needs_sync, true) # rubocop:disable Rails/SkipsModelValidations
GoogleCalendarSyncJob.perform_later(current_user, force: true)
render json: {
message: "Notifications enabled",
notifications_disabled: false,
notifications_disabled_until: nil
}, status: :ok
rescue => e
Rails.logger.error("Error enabling notifications for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to enable notifications" }, status: :internal_server_error
end
private
# Resolves the account for a verified Google email, in this order:
# 1. a user already keyed to that Google email
# 2. a user who has connected that Google account for calendar sync
# (oauth_credentials are OAuth-verified, so this link is trustworthy)
# 3. otherwise, a fresh account keyed to the Google email
#
# Note: we deliberately do NOT link by a client-supplied WIT email — that
# value isn't verified, so trusting it would let a caller attach their token
# to someone else's existing account. Legacy accounts that never connected a
# Google account can't be auto-linked safely and start fresh.
def find_or_create_onboarding_user(google_email, preferred_name, wit_email = nil)
wit_email = wit_email.to_s.strip.downcase.presence
existing = User.find_by(email: google_email) ||
User.joins(:oauth_credentials)
.find_by(oauth_credentials: { email: google_email, provider: "google" })
if existing
# Record the WIT email as metadata on the caller's OWN account (resolved
# from the verified token). It's never used to decide which account this
# is, so a forged value can only mislabel the caller's own record.
if wit_email && existing.wit_email != wit_email
existing.update_column(:wit_email, wit_email) # rubocop:disable Rails/SkipsModelValidations
end
return existing
end
first_name, last_name = preferred_name.to_s.strip.split(" ", 2)
User.create!(
email: google_email,
wit_email: wit_email,
first_name: first_name,
last_name: last_name,
password: SecureRandom.hex(24)
)
end
end
end