From 748bd151ff72754cc11f2ac63e42897e9c1b5e47 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sun, 9 Aug 2026 19:41:10 +0200 Subject: [PATCH 1/4] feat(db): add check_in_code and source columns for self check-in Adds check_in_code to events and workshops, and source to invitations and workshop_invitations, with regenerated schema. --- db/migrate/20260703083036_add_check_in_code_to_events.rb | 8 ++++++++ .../20260703083039_add_check_in_code_to_workshops.rb | 8 ++++++++ db/migrate/20260703083048_add_source_to_invitations.rb | 5 +++++ .../20260703083051_add_source_to_workshop_invitations.rb | 5 +++++ db/schema.rb | 6 ++++++ 5 files changed, 32 insertions(+) create mode 100644 db/migrate/20260703083036_add_check_in_code_to_events.rb create mode 100644 db/migrate/20260703083039_add_check_in_code_to_workshops.rb create mode 100644 db/migrate/20260703083048_add_source_to_invitations.rb create mode 100644 db/migrate/20260703083051_add_source_to_workshop_invitations.rb diff --git a/db/migrate/20260703083036_add_check_in_code_to_events.rb b/db/migrate/20260703083036_add_check_in_code_to_events.rb new file mode 100644 index 000000000..1e8c244db --- /dev/null +++ b/db/migrate/20260703083036_add_check_in_code_to_events.rb @@ -0,0 +1,8 @@ +class AddCheckInCodeToEvents < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + add_column :events, :check_in_code, :string + add_index :events, :check_in_code, unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20260703083039_add_check_in_code_to_workshops.rb b/db/migrate/20260703083039_add_check_in_code_to_workshops.rb new file mode 100644 index 000000000..c56db57e7 --- /dev/null +++ b/db/migrate/20260703083039_add_check_in_code_to_workshops.rb @@ -0,0 +1,8 @@ +class AddCheckInCodeToWorkshops < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + add_column :workshops, :check_in_code, :string + add_index :workshops, :check_in_code, unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20260703083048_add_source_to_invitations.rb b/db/migrate/20260703083048_add_source_to_invitations.rb new file mode 100644 index 000000000..776308173 --- /dev/null +++ b/db/migrate/20260703083048_add_source_to_invitations.rb @@ -0,0 +1,5 @@ +class AddSourceToInvitations < ActiveRecord::Migration[8.1] + def change + add_column :invitations, :source, :string + end +end diff --git a/db/migrate/20260703083051_add_source_to_workshop_invitations.rb b/db/migrate/20260703083051_add_source_to_workshop_invitations.rb new file mode 100644 index 000000000..9b280fa83 --- /dev/null +++ b/db/migrate/20260703083051_add_source_to_workshop_invitations.rb @@ -0,0 +1,5 @@ +class AddSourceToWorkshopInvitations < ActiveRecord::Migration[8.1] + def change + add_column :workshop_invitations, :source, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 6820b6fcd..030f2d89b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -204,6 +204,7 @@ create_table "events", id: :serial, force: :cascade do |t| t.boolean "announce_only" t.string "audience" + t.string "check_in_code" t.text "coach_description" t.string "coach_questionnaire" t.integer "coach_spaces" @@ -231,6 +232,7 @@ t.string "url" t.integer "venue_id" t.boolean "virtual", default: false, null: false + t.index ["check_in_code"], name: "index_events_on_check_in_code", unique: true t.index ["date_and_time"], name: "index_events_on_date_and_time" t.index ["slug"], name: "index_events_on_slug", unique: true t.index ["venue_id"], name: "index_events_on_venue_id" @@ -344,6 +346,7 @@ t.integer "member_id" t.text "note" t.string "role" + t.string "source" t.string "token" t.datetime "updated_at", precision: nil t.boolean "verified" @@ -598,6 +601,7 @@ t.datetime "reminded_at", precision: nil t.string "role" t.datetime "rsvp_time", precision: nil + t.string "source" t.string "token" t.text "tutorial" t.datetime "updated_at", precision: nil @@ -624,6 +628,7 @@ create_table "workshops", id: :serial, force: :cascade do |t| t.integer "chapter_id" + t.string "check_in_code" t.integer "coach_spaces", default: 0 t.datetime "created_at", precision: nil t.datetime "date_and_time", precision: nil @@ -639,6 +644,7 @@ t.string "title" t.datetime "updated_at", precision: nil t.boolean "virtual", default: false + t.index ["check_in_code"], name: "index_workshops_on_check_in_code", unique: true t.index ["chapter_id"], name: "index_workshops_on_chapter_id" t.index ["date_and_time"], name: "index_workshops_on_date_and_time" end From 3ce4ab43b6272ee179182f4ad8b876d5b5e65af6 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sun, 9 Aug 2026 19:41:29 +0200 Subject: [PATCH 2/4] feat(models): add CheckInCode concern and source tracking Events and workshops generate a check-in code on create. Invitations now record their source (admin, check-in, etc.) via shared constants. --- .../admin/invitation_controller.rb | 4 +- .../admin/invitations_controller.rb | 5 +- app/models/concerns/check_in_code.rb | 75 + app/models/concerns/invitation_concerns.rb | 3 + app/models/event.rb | 1 + app/models/workshop.rb | 1 + lib/words/README.md | 7 + lib/words/check_in_words.txt | 7780 +++++++++++++++++ spec/models/event_spec.rb | 13 + spec/models/workshop_spec.rb | 13 + 10 files changed, 7898 insertions(+), 4 deletions(-) create mode 100644 app/models/concerns/check_in_code.rb create mode 100644 lib/words/README.md create mode 100644 lib/words/check_in_words.txt diff --git a/app/controllers/admin/invitation_controller.rb b/app/controllers/admin/invitation_controller.rb index 0b9be860c..a8eff1bb4 100644 --- a/app/controllers/admin/invitation_controller.rb +++ b/app/controllers/admin/invitation_controller.rb @@ -5,7 +5,7 @@ class Admin::InvitationController < Admin::ApplicationController def update invitation = Invitation.find_by(token: params[:invitation][:id]) - invitation.update(attending: true, verified: true, verified_by: current_user) + invitation.update(attending: true, verified: true, verified_by: current_user, source: Invitation::SOURCE_ADMIN) EventInvitationMailer.attending(invitation.event, invitation.member, invitation).deliver_now @@ -17,7 +17,7 @@ def update def verify invitation = Invitation.find_by(token: params[:invitation_id]) - invitation.update(verified: true, verified_by_id: current_user.id) + invitation.update(verified: true, verified_by_id: current_user.id, source: Invitation::SOURCE_ADMIN) EventInvitationMailer.attending(invitation.event, invitation.member, invitation).deliver_now diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index 381c811ea..7054e79fc 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -39,7 +39,7 @@ def update_attendance(attending:, attended:) end def update_to_attended - @invitation.update(attended: true) + @invitation.update(attended: true, source: Invitation::SOURCE_ADMIN) end def update_to_unattended @@ -51,7 +51,8 @@ def update_to_attending attending: true, rsvp_time: Time.zone.now, automated_rsvp: true, - last_overridden_by_id: current_user.id + last_overridden_by_id: current_user.id, + source: Invitation::SOURCE_ADMIN ) { diff --git a/app/models/concerns/check_in_code.rb b/app/models/concerns/check_in_code.rb new file mode 100644 index 000000000..3b5aa2861 --- /dev/null +++ b/app/models/concerns/check_in_code.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module CheckInCode + extend ActiveSupport::Concern + + # Check-in codes are built from the EFF long wordlist for random passphrases. + # Source: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt + # More info: https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases + WORD_LIST_PATH = Rails.root.join('lib/words/check_in_words.txt') + MAX_ATTEMPTS = 100 + CHECK_IN_WINDOW_START_OFFSET = 1.hour + CHECK_IN_WINDOW_END_FALLBACK = 2.hours + + class_methods do + def word_list + @word_list ||= File.readlines(WORD_LIST_PATH) + .map(&:strip) + .reject { |line| line.empty? || line.start_with?('#') } + .freeze + end + end + + included do + before_create :set_check_in_code + end + + def generate_check_in_code! + attempts = 0 + loop do + update!(check_in_code: unique_check_in_code) + return check_in_code + rescue ActiveRecord::RecordNotUnique + attempts += 1 + raise if attempts >= MAX_ATTEMPTS + end + end + + def check_in_open? + now = Time.zone.now + return false unless date_and_time + + window_start = date_and_time - CHECK_IN_WINDOW_START_OFFSET + window_end = ends_at || date_and_time + CHECK_IN_WINDOW_END_FALLBACK + now >= window_start && now <= window_end + end + + def spaces_available_for?(role) + spaces = role == 'Student' ? student_spaces : coach_spaces + attending = role == 'Student' ? attending_students.count : attending_coaches.count + attending < spaces + end + + def check_in_url + prefix = model_name.singular == 'event' ? 'e' : 'w' + route_name = :"check_in_#{prefix}_url" + Rails.application.routes.url_helpers.public_send( + route_name, check_in_code + ) + end + + private + + def unique_check_in_code + MAX_ATTEMPTS.times do + code = self.class.word_list.sample(3).join('-') + return code unless self.class.exists?(check_in_code: code) + end + + raise 'Unable to generate a unique check-in code' + end + + def set_check_in_code + self.check_in_code = unique_check_in_code + end +end diff --git a/app/models/concerns/invitation_concerns.rb b/app/models/concerns/invitation_concerns.rb index 65b050e62..62f6d6399 100644 --- a/app/models/concerns/invitation_concerns.rb +++ b/app/models/concerns/invitation_concerns.rb @@ -1,6 +1,9 @@ module InvitationConcerns extend ActiveSupport::Concern + SOURCE_ADMIN = 'admin'.freeze + SOURCE_CHECK_IN = 'check_in'.freeze + included do include InstanceMethods diff --git a/app/models/event.rb b/app/models/event.rb index 73219aa75..636d89cc2 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -2,6 +2,7 @@ class Event < ApplicationRecord include DateTimeConcerns include Listable include Invitable + include CheckInCode attr_accessor :local_date, :local_time, :local_end_time diff --git a/app/models/workshop.rb b/app/models/workshop.rb index d89f23f13..b4a158acf 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -1,6 +1,7 @@ class Workshop < ApplicationRecord include DateTimeConcerns include Invitable + include CheckInCode include Listable attr_accessor :local_date, :local_time, :local_end_time, :rsvp_open_local_date, :rsvp_open_local_time, diff --git a/lib/words/README.md b/lib/words/README.md new file mode 100644 index 000000000..dc1d401b8 --- /dev/null +++ b/lib/words/README.md @@ -0,0 +1,7 @@ +# Check-in word list + +`check_in_words.txt` is the [EFF long wordlist](https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases) used to generate human-readable, memorable check-in codes for events and workshops. + +- Source file: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt +- 7,776 words, allowing roughly 4.7×10^11 unique three-word combinations. +- To update the list, replace `check_in_words.txt` with a new word list and ensure `CheckInCode.word_list` still filters out blank lines and comment lines. diff --git a/lib/words/check_in_words.txt b/lib/words/check_in_words.txt new file mode 100644 index 000000000..306c20f76 --- /dev/null +++ b/lib/words/check_in_words.txt @@ -0,0 +1,7780 @@ +# EFF long wordlist for random passphrases (7776 words). +# Source: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt +# See: https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases + +abacus +abdomen +abdominal +abide +abiding +ability +ablaze +able +abnormal +abrasion +abrasive +abreast +abridge +abroad +abruptly +absence +absentee +absently +absinthe +absolute +absolve +abstain +abstract +absurd +accent +acclaim +acclimate +accompany +account +accuracy +accurate +accustom +acetone +achiness +aching +acid +acorn +acquaint +acquire +acre +acrobat +acronym +acting +action +activate +activator +active +activism +activist +activity +actress +acts +acutely +acuteness +aeration +aerobics +aerosol +aerospace +afar +affair +affected +affecting +affection +affidavit +affiliate +affirm +affix +afflicted +affluent +afford +affront +aflame +afloat +aflutter +afoot +afraid +afterglow +afterlife +aftermath +aftermost +afternoon +aged +ageless +agency +agenda +agent +aggregate +aghast +agile +agility +aging +agnostic +agonize +agonizing +agony +agreeable +agreeably +agreed +agreeing +agreement +aground +ahead +ahoy +aide +aids +aim +ajar +alabaster +alarm +albatross +album +alfalfa +algebra +algorithm +alias +alibi +alienable +alienate +aliens +alike +alive +alkaline +alkalize +almanac +almighty +almost +aloe +aloft +aloha +alone +alongside +aloof +alphabet +alright +although +altitude +alto +aluminum +alumni +always +amaretto +amaze +amazingly +amber +ambiance +ambiguity +ambiguous +ambition +ambitious +ambulance +ambush +amendable +amendment +amends +amenity +amiable +amicably +amid +amigo +amino +amiss +ammonia +ammonium +amnesty +amniotic +among +amount +amperage +ample +amplifier +amplify +amply +amuck +amulet +amusable +amused +amusement +amuser +amusing +anaconda +anaerobic +anagram +anatomist +anatomy +anchor +anchovy +ancient +android +anemia +anemic +aneurism +anew +angelfish +angelic +anger +angled +angler +angles +angling +angrily +angriness +anguished +angular +animal +animate +animating +animation +animator +anime +animosity +ankle +annex +annotate +announcer +annoying +annually +annuity +anointer +another +answering +antacid +antarctic +anteater +antelope +antennae +anthem +anthill +anthology +antibody +antics +antidote +antihero +antiquely +antiques +antiquity +antirust +antitoxic +antitrust +antiviral +antivirus +antler +antonym +antsy +anvil +anybody +anyhow +anymore +anyone +anyplace +anything +anytime +anyway +anywhere +aorta +apache +apostle +appealing +appear +appease +appeasing +appendage +appendix +appetite +appetizer +applaud +applause +apple +appliance +applicant +applied +apply +appointee +appraisal +appraiser +apprehend +approach +approval +approve +apricot +april +apron +aptitude +aptly +aqua +aqueduct +arbitrary +arbitrate +ardently +area +arena +arguable +arguably +argue +arise +armadillo +armband +armchair +armed +armful +armhole +arming +armless +armoire +armored +armory +armrest +army +aroma +arose +around +arousal +arrange +array +arrest +arrival +arrive +arrogance +arrogant +arson +art +ascend +ascension +ascent +ascertain +ashamed +ashen +ashes +ashy +aside +askew +asleep +asparagus +aspect +aspirate +aspire +aspirin +astonish +astound +astride +astrology +astronaut +astronomy +astute +atlantic +atlas +atom +atonable +atop +atrium +atrocious +atrophy +attach +attain +attempt +attendant +attendee +attention +attentive +attest +attic +attire +attitude +attractor +attribute +atypical +auction +audacious +audacity +audible +audibly +audience +audio +audition +augmented +august +authentic +author +autism +autistic +autograph +automaker +automated +automatic +autopilot +available +avalanche +avatar +avenge +avenging +avenue +average +aversion +avert +aviation +aviator +avid +avoid +await +awaken +award +aware +awhile +awkward +awning +awoke +awry +axis +babble +babbling +babied +baboon +backache +backboard +backboned +backdrop +backed +backer +backfield +backfire +backhand +backing +backlands +backlash +backless +backlight +backlit +backlog +backpack +backpedal +backrest +backroom +backshift +backside +backslid +backspace +backspin +backstab +backstage +backtalk +backtrack +backup +backward +backwash +backwater +backyard +bacon +bacteria +bacterium +badass +badge +badland +badly +badness +baffle +baffling +bagel +bagful +baggage +bagged +baggie +bagginess +bagging +baggy +bagpipe +baguette +baked +bakery +bakeshop +baking +balance +balancing +balcony +balmy +balsamic +bamboo +banana +banish +banister +banjo +bankable +bankbook +banked +banker +banking +banknote +bankroll +banner +bannister +banshee +banter +barbecue +barbed +barbell +barber +barcode +barge +bargraph +barista +baritone +barley +barmaid +barman +barn +barometer +barrack +barracuda +barrel +barrette +barricade +barrier +barstool +bartender +barterer +bash +basically +basics +basil +basin +basis +basket +batboy +batch +bath +baton +bats +battalion +battered +battering +battery +batting +battle +bauble +bazooka +blabber +bladder +blade +blah +blame +blaming +blanching +blandness +blank +blaspheme +blasphemy +blast +blatancy +blatantly +blazer +blazing +bleach +bleak +bleep +blemish +blend +bless +blighted +blimp +bling +blinked +blinker +blinking +blinks +blip +blissful +blitz +blizzard +bloated +bloating +blob +blog +bloomers +blooming +blooper +blot +blouse +blubber +bluff +bluish +blunderer +blunt +blurb +blurred +blurry +blurt +blush +blustery +boaster +boastful +boasting +boat +bobbed +bobbing +bobble +bobcat +bobsled +bobtail +bodacious +body +bogged +boggle +bogus +boil +bok +bolster +bolt +bonanza +bonded +bonding +bondless +boned +bonehead +boneless +bonelike +boney +bonfire +bonnet +bonsai +bonus +bony +boogeyman +boogieman +book +boondocks +booted +booth +bootie +booting +bootlace +bootleg +boots +boozy +borax +boring +borough +borrower +borrowing +boss +botanical +botanist +botany +botch +both +bottle +bottling +bottom +bounce +bouncing +bouncy +bounding +boundless +bountiful +bovine +boxcar +boxer +boxing +boxlike +boxy +breach +breath +breeches +breeching +breeder +breeding +breeze +breezy +brethren +brewery +brewing +briar +bribe +brick +bride +bridged +brigade +bright +brilliant +brim +bring +brink +brisket +briskly +briskness +bristle +brittle +broadband +broadcast +broaden +broadly +broadness +broadside +broadways +broiler +broiling +broken +broker +bronchial +bronco +bronze +bronzing +brook +broom +brought +browbeat +brownnose +browse +browsing +bruising +brunch +brunette +brunt +brush +brussels +brute +brutishly +bubble +bubbling +bubbly +buccaneer +bucked +bucket +buckle +buckshot +buckskin +bucktooth +buckwheat +buddhism +buddhist +budding +buddy +budget +buffalo +buffed +buffer +buffing +buffoon +buggy +bulb +bulge +bulginess +bulgur +bulk +bulldog +bulldozer +bullfight +bullfrog +bullhorn +bullion +bullish +bullpen +bullring +bullseye +bullwhip +bully +bunch +bundle +bungee +bunion +bunkbed +bunkhouse +bunkmate +bunny +bunt +busboy +bush +busily +busload +bust +busybody +buzz +cabana +cabbage +cabbie +cabdriver +cable +caboose +cache +cackle +cacti +cactus +caddie +caddy +cadet +cadillac +cadmium +cage +cahoots +cake +calamari +calamity +calcium +calculate +calculus +caliber +calibrate +calm +caloric +calorie +calzone +camcorder +cameo +camera +camisole +camper +campfire +camping +campsite +campus +canal +canary +cancel +candied +candle +candy +cane +canine +canister +cannabis +canned +canning +cannon +cannot +canola +canon +canopener +canopy +canteen +canyon +capable +capably +capacity +cape +capillary +capital +capitol +capped +capricorn +capsize +capsule +caption +captivate +captive +captivity +capture +caramel +carat +caravan +carbon +cardboard +carded +cardiac +cardigan +cardinal +cardstock +carefully +caregiver +careless +caress +caretaker +cargo +caring +carless +carload +carmaker +carnage +carnation +carnival +carnivore +carol +carpenter +carpentry +carpool +carport +carried +carrot +carrousel +carry +cartel +cartload +carton +cartoon +cartridge +cartwheel +carve +carving +carwash +cascade +case +cash +casing +casino +casket +cassette +casually +casualty +catacomb +catalog +catalyst +catalyze +catapult +cataract +catatonic +catcall +catchable +catcher +catching +catchy +caterer +catering +catfight +catfish +cathedral +cathouse +catlike +catnap +catnip +catsup +cattail +cattishly +cattle +catty +catwalk +caucasian +caucus +causal +causation +cause +causing +cauterize +caution +cautious +cavalier +cavalry +caviar +cavity +cedar +celery +celestial +celibacy +celibate +celtic +cement +census +ceramics +ceremony +certainly +certainty +certified +certify +cesarean +cesspool +chafe +chaffing +chain +chair +chalice +challenge +chamber +chamomile +champion +chance +change +channel +chant +chaos +chaperone +chaplain +chapped +chaps +chapter +character +charbroil +charcoal +charger +charging +chariot +charity +charm +charred +charter +charting +chase +chasing +chaste +chastise +chastity +chatroom +chatter +chatting +chatty +cheating +cheddar +cheek +cheer +cheese +cheesy +chef +chemicals +chemist +chemo +cherisher +cherub +chess +chest +chevron +chevy +chewable +chewer +chewing +chewy +chief +chihuahua +childcare +childhood +childish +childless +childlike +chili +chill +chimp +chip +chirping +chirpy +chitchat +chivalry +chive +chloride +chlorine +choice +chokehold +choking +chomp +chooser +choosing +choosy +chop +chosen +chowder +chowtime +chrome +chubby +chuck +chug +chummy +chump +chunk +churn +chute +cider +cilantro +cinch +cinema +cinnamon +circle +circling +circular +circulate +circus +citable +citadel +citation +citizen +citric +citrus +city +civic +civil +clad +claim +clambake +clammy +clamor +clamp +clamshell +clang +clanking +clapped +clapper +clapping +clarify +clarinet +clarity +clash +clasp +class +clatter +clause +clavicle +claw +clay +clean +clear +cleat +cleaver +cleft +clench +clergyman +clerical +clerk +clever +clicker +client +climate +climatic +cling +clinic +clinking +clip +clique +cloak +clobber +clock +clone +cloning +closable +closure +clothes +clothing +cloud +clover +clubbed +clubbing +clubhouse +clump +clumsily +clumsy +clunky +clustered +clutch +clutter +coach +coagulant +coastal +coaster +coasting +coastland +coastline +coat +coauthor +cobalt +cobbler +cobweb +cocoa +coconut +cod +coeditor +coerce +coexist +coffee +cofounder +cognition +cognitive +cogwheel +coherence +coherent +cohesive +coil +coke +cola +cold +coleslaw +coliseum +collage +collapse +collar +collected +collector +collide +collie +collision +colonial +colonist +colonize +colony +colossal +colt +coma +come +comfort +comfy +comic +coming +comma +commence +commend +comment +commerce +commode +commodity +commodore +common +commotion +commute +commuting +compacted +compacter +compactly +compactor +companion +company +compare +compel +compile +comply +component +composed +composer +composite +compost +composure +compound +compress +comprised +computer +computing +comrade +concave +conceal +conceded +concept +concerned +concert +conch +concierge +concise +conclude +concrete +concur +condense +condiment +condition +condone +conducive +conductor +conduit +cone +confess +confetti +confidant +confident +confider +confiding +configure +confined +confining +confirm +conflict +conform +confound +confront +confused +confusing +confusion +congenial +congested +congrats +congress +conical +conjoined +conjure +conjuror +connected +connector +consensus +consent +console +consoling +consonant +constable +constant +constrain +constrict +construct +consult +consumer +consuming +contact +container +contempt +contend +contented +contently +contents +contest +context +contort +contour +contrite +control +contusion +convene +convent +copartner +cope +copied +copier +copilot +coping +copious +copper +copy +coral +cork +cornball +cornbread +corncob +cornea +corned +corner +cornfield +cornflake +cornhusk +cornmeal +cornstalk +corny +coronary +coroner +corporal +corporate +corral +correct +corridor +corrode +corroding +corrosive +corsage +corset +cortex +cosigner +cosmetics +cosmic +cosmos +cosponsor +cost +cottage +cotton +couch +cough +could +countable +countdown +counting +countless +country +county +courier +covenant +cover +coveted +coveting +coyness +cozily +coziness +cozy +crabbing +crabgrass +crablike +crabmeat +cradle +cradling +crafter +craftily +craftsman +craftwork +crafty +cramp +cranberry +crane +cranial +cranium +crank +crate +crave +craving +crawfish +crawlers +crawling +crayfish +crayon +crazed +crazily +craziness +crazy +creamed +creamer +creamlike +crease +creasing +creatable +create +creation +creative +creature +credible +credibly +credit +creed +creme +creole +crepe +crept +crescent +crested +cresting +crestless +crevice +crewless +crewman +crewmate +crib +cricket +cried +crier +crimp +crimson +cringe +cringing +crinkle +crinkly +crisped +crisping +crisply +crispness +crispy +criteria +critter +croak +crock +crook +croon +crop +cross +crouch +crouton +crowbar +crowd +crown +crucial +crudely +crudeness +cruelly +cruelness +cruelty +crumb +crummiest +crummy +crumpet +crumpled +cruncher +crunching +crunchy +crusader +crushable +crushed +crusher +crushing +crust +crux +crying +cryptic +crystal +cubbyhole +cube +cubical +cubicle +cucumber +cuddle +cuddly +cufflink +culinary +culminate +culpable +culprit +cultivate +cultural +culture +cupbearer +cupcake +cupid +cupped +cupping +curable +curator +curdle +cure +curfew +curing +curled +curler +curliness +curling +curly +curry +curse +cursive +cursor +curtain +curtly +curtsy +curvature +curve +curvy +cushy +cusp +cussed +custard +custodian +custody +customary +customer +customize +customs +cut +cycle +cyclic +cycling +cyclist +cylinder +cymbal +cytoplasm +cytoplast +dab +dad +daffodil +dagger +daily +daintily +dainty +dairy +daisy +dallying +dance +dancing +dandelion +dander +dandruff +dandy +danger +dangle +dangling +daredevil +dares +daringly +darkened +darkening +darkish +darkness +darkroom +darling +darn +dart +darwinism +dash +dastardly +data +datebook +dating +daughter +daunting +dawdler +dawn +daybed +daybreak +daycare +daydream +daylight +daylong +dayroom +daytime +dazzler +dazzling +deacon +deafening +deafness +dealer +dealing +dealmaker +dealt +dean +debatable +debate +debating +debit +debrief +debtless +debtor +debug +debunk +decade +decaf +decal +decathlon +decay +deceased +deceit +deceiver +deceiving +december +decency +decent +deception +deceptive +decibel +decidable +decimal +decimeter +decipher +deck +declared +decline +decode +decompose +decorated +decorator +decoy +decrease +decree +dedicate +dedicator +deduce +deduct +deed +deem +deepen +deeply +deepness +deface +defacing +defame +default +defeat +defection +defective +defendant +defender +defense +defensive +deferral +deferred +defiance +defiant +defile +defiling +define +definite +deflate +deflation +deflator +deflected +deflector +defog +deforest +defraud +defrost +deftly +defuse +defy +degraded +degrading +degrease +degree +dehydrate +deity +dejected +delay +delegate +delegator +delete +deletion +delicacy +delicate +delicious +delighted +delirious +delirium +deliverer +delivery +delouse +delta +deluge +delusion +deluxe +demanding +demeaning +demeanor +demise +democracy +democrat +demote +demotion +demystify +denatured +deniable +denial +denim +denote +dense +density +dental +dentist +denture +deny +deodorant +deodorize +departed +departure +depict +deplete +depletion +deplored +deploy +deport +depose +depraved +depravity +deprecate +depress +deprive +depth +deputize +deputy +derail +deranged +derby +derived +desecrate +deserve +deserving +designate +designed +designer +designing +deskbound +desktop +deskwork +desolate +despair +despise +despite +destiny +destitute +destruct +detached +detail +detection +detective +detector +detention +detergent +detest +detonate +detonator +detoxify +detract +deuce +devalue +deviancy +deviant +deviate +deviation +deviator +device +devious +devotedly +devotee +devotion +devourer +devouring +devoutly +dexterity +dexterous +diabetes +diabetic +diabolic +diagnoses +diagnosis +diagram +dial +diameter +diaper +diaphragm +diary +dice +dicing +dictate +dictation +dictator +difficult +diffused +diffuser +diffusion +diffusive +dig +dilation +diligence +diligent +dill +dilute +dime +diminish +dimly +dimmed +dimmer +dimness +dimple +diner +dingbat +dinghy +dinginess +dingo +dingy +dining +dinner +diocese +dioxide +diploma +dipped +dipper +dipping +directed +direction +directive +directly +directory +direness +dirtiness +disabled +disagree +disallow +disarm +disarray +disaster +disband +disbelief +disburse +discard +discern +discharge +disclose +discolor +discount +discourse +discover +discuss +disdain +disengage +disfigure +disgrace +dish +disinfect +disjoin +disk +dislike +disliking +dislocate +dislodge +disloyal +dismantle +dismay +dismiss +dismount +disobey +disorder +disown +disparate +disparity +dispatch +dispense +dispersal +dispersed +disperser +displace +display +displease +disposal +dispose +disprove +dispute +disregard +disrupt +dissuade +distance +distant +distaste +distill +distinct +distort +distract +distress +district +distrust +ditch +ditto +ditzy +dividable +divided +dividend +dividers +dividing +divinely +diving +divinity +divisible +divisibly +division +divisive +divorcee +dizziness +dizzy +doable +docile +dock +doctrine +document +dodge +dodgy +doily +doing +dole +dollar +dollhouse +dollop +dolly +dolphin +domain +domelike +domestic +dominion +dominoes +donated +donation +donator +donor +donut +doodle +doorbell +doorframe +doorknob +doorman +doormat +doornail +doorpost +doorstep +doorstop +doorway +doozy +dork +dormitory +dorsal +dosage +dose +dotted +doubling +douche +dove +down +dowry +doze +drab +dragging +dragonfly +dragonish +dragster +drainable +drainage +drained +drainer +drainpipe +dramatic +dramatize +drank +drapery +drastic +draw +dreaded +dreadful +dreadlock +dreamboat +dreamily +dreamland +dreamless +dreamlike +dreamt +dreamy +drearily +dreary +drench +dress +drew +dribble +dried +drier +drift +driller +drilling +drinkable +drinking +dripping +drippy +drivable +driven +driver +driveway +driving +drizzle +drizzly +drone +drool +droop +drop-down +dropbox +dropkick +droplet +dropout +dropper +drove +drown +drowsily +drudge +drum +dry +dubbed +dubiously +duchess +duckbill +ducking +duckling +ducktail +ducky +duct +dude +duffel +dugout +duh +duke +duller +dullness +duly +dumping +dumpling +dumpster +duo +dupe +duplex +duplicate +duplicity +durable +durably +duration +duress +during +dusk +dust +dutiful +duty +duvet +dwarf +dweeb +dwelled +dweller +dwelling +dwindle +dwindling +dynamic +dynamite +dynasty +dyslexia +dyslexic +each +eagle +earache +eardrum +earflap +earful +earlobe +early +earmark +earmuff +earphone +earpiece +earplugs +earring +earshot +earthen +earthlike +earthling +earthly +earthworm +earthy +earwig +easeful +easel +easiest +easily +easiness +easing +eastbound +eastcoast +easter +eastward +eatable +eaten +eatery +eating +eats +ebay +ebony +ebook +ecard +eccentric +echo +eclair +eclipse +ecologist +ecology +economic +economist +economy +ecosphere +ecosystem +edge +edginess +edging +edgy +edition +editor +educated +education +educator +eel +effective +effects +efficient +effort +eggbeater +egging +eggnog +eggplant +eggshell +egomaniac +egotism +egotistic +either +eject +elaborate +elastic +elated +elbow +eldercare +elderly +eldest +electable +election +elective +elephant +elevate +elevating +elevation +elevator +eleven +elf +eligible +eligibly +eliminate +elite +elitism +elixir +elk +ellipse +elliptic +elm +elongated +elope +eloquence +eloquent +elsewhere +elude +elusive +elves +email +embargo +embark +embassy +embattled +embellish +ember +embezzle +emblaze +emblem +embody +embolism +emboss +embroider +emcee +emerald +emergency +emission +emit +emote +emoticon +emotion +empathic +empathy +emperor +emphases +emphasis +emphasize +emphatic +empirical +employed +employee +employer +emporium +empower +emptier +emptiness +empty +emu +enable +enactment +enamel +enchanted +enchilada +encircle +enclose +enclosure +encode +encore +encounter +encourage +encroach +encrust +encrypt +endanger +endeared +endearing +ended +ending +endless +endnote +endocrine +endorphin +endorse +endowment +endpoint +endurable +endurance +enduring +energetic +energize +energy +enforced +enforcer +engaged +engaging +engine +engorge +engraved +engraver +engraving +engross +engulf +enhance +enigmatic +enjoyable +enjoyably +enjoyer +enjoying +enjoyment +enlarged +enlarging +enlighten +enlisted +enquirer +enrage +enrich +enroll +enslave +ensnare +ensure +entail +entangled +entering +entertain +enticing +entire +entitle +entity +entomb +entourage +entrap +entree +entrench +entrust +entryway +entwine +enunciate +envelope +enviable +enviably +envious +envision +envoy +envy +enzyme +epic +epidemic +epidermal +epidermis +epidural +epilepsy +epileptic +epilogue +epiphany +episode +equal +equate +equation +equator +equinox +equipment +equity +equivocal +eradicate +erasable +erased +eraser +erasure +ergonomic +errand +errant +erratic +error +erupt +escalate +escalator +escapable +escapade +escapist +escargot +eskimo +esophagus +espionage +espresso +esquire +essay +essence +essential +establish +estate +esteemed +estimate +estimator +estranged +estrogen +etching +eternal +eternity +ethanol +ether +ethically +ethics +euphemism +evacuate +evacuee +evade +evaluate +evaluator +evaporate +evasion +evasive +even +everglade +evergreen +everybody +everyday +everyone +evict +evidence +evident +evil +evoke +evolution +evolve +exact +exalted +example +excavate +excavator +exceeding +exception +excess +exchange +excitable +exciting +exclaim +exclude +excluding +exclusion +exclusive +excretion +excretory +excursion +excusable +excusably +excuse +exemplary +exemplify +exemption +exerciser +exert +exes +exfoliate +exhale +exhaust +exhume +exile +existing +exit +exodus +exonerate +exorcism +exorcist +expand +expanse +expansion +expansive +expectant +expedited +expediter +expel +expend +expenses +expensive +expert +expire +expiring +explain +expletive +explicit +explode +exploit +explore +exploring +exponent +exporter +exposable +expose +exposure +express +expulsion +exquisite +extended +extending +extent +extenuate +exterior +external +extinct +extortion +extradite +extras +extrovert +extrude +extruding +exuberant +fable +fabric +fabulous +facebook +facecloth +facedown +faceless +facelift +faceplate +faceted +facial +facility +facing +facsimile +faction +factoid +factor +factsheet +factual +faculty +fade +fading +failing +falcon +fall +false +falsify +fame +familiar +family +famine +famished +fanatic +fancied +fanciness +fancy +fanfare +fang +fanning +fantasize +fantastic +fantasy +fascism +fastball +faster +fasting +fastness +faucet +favorable +favorably +favored +favoring +favorite +fax +feast +federal +fedora +feeble +feed +feel +feisty +feline +felt-tip +feminine +feminism +feminist +feminize +femur +fence +fencing +fender +ferment +fernlike +ferocious +ferocity +ferret +ferris +ferry +fervor +fester +festival +festive +festivity +fetal +fetch +fever +fiber +fiction +fiddle +fiddling +fidelity +fidgeting +fidgety +fifteen +fifth +fiftieth +fifty +figment +figure +figurine +filing +filled +filler +filling +film +filter +filth +filtrate +finale +finalist +finalize +finally +finance +financial +finch +fineness +finer +finicky +finished +finisher +finishing +finite +finless +finlike +fiscally +fit +five +flaccid +flagman +flagpole +flagship +flagstick +flagstone +flail +flakily +flaky +flame +flammable +flanked +flanking +flannels +flap +flaring +flashback +flashbulb +flashcard +flashily +flashing +flashy +flask +flatbed +flatfoot +flatly +flatness +flatten +flattered +flatterer +flattery +flattop +flatware +flatworm +flavored +flavorful +flavoring +flaxseed +fled +fleshed +fleshy +flick +flier +flight +flinch +fling +flint +flip +flirt +float +flock +flogging +flop +floral +florist +floss +flounder +flyable +flyaway +flyer +flying +flyover +flypaper +foam +foe +fog +foil +folic +folk +follicle +follow +fondling +fondly +fondness +fondue +font +food +fool +footage +football +footbath +footboard +footer +footgear +foothill +foothold +footing +footless +footman +footnote +footpad +footpath +footprint +footrest +footsie +footsore +footwear +footwork +fossil +foster +founder +founding +fountain +fox +foyer +fraction +fracture +fragile +fragility +fragment +fragrance +fragrant +frail +frame +framing +frantic +fraternal +frayed +fraying +frays +freckled +freckles +freebase +freebee +freebie +freedom +freefall +freehand +freeing +freeload +freely +freemason +freeness +freestyle +freeware +freeway +freewill +freezable +freezing +freight +french +frenzied +frenzy +frequency +frequent +fresh +fretful +fretted +friction +friday +fridge +fried +friend +frighten +frightful +frigidity +frigidly +frill +fringe +frisbee +frisk +fritter +frivolous +frolic +from +front +frostbite +frosted +frostily +frosting +frostlike +frosty +froth +frown +frozen +fructose +frugality +frugally +fruit +frustrate +frying +gab +gaffe +gag +gainfully +gaining +gains +gala +gallantly +galleria +gallery +galley +gallon +gallows +gallstone +galore +galvanize +gambling +game +gaming +gamma +gander +gangly +gangrene +gangway +gap +garage +garbage +garden +gargle +garland +garlic +garment +garnet +garnish +garter +gas +gatherer +gathering +gating +gauging +gauntlet +gauze +gave +gawk +gazing +gear +gecko +geek +geiger +gem +gender +generic +generous +genetics +genre +gentile +gentleman +gently +gents +geography +geologic +geologist +geology +geometric +geometry +geranium +gerbil +geriatric +germicide +germinate +germless +germproof +gestate +gestation +gesture +getaway +getting +getup +giant +gibberish +giblet +giddily +giddiness +giddy +gift +gigabyte +gigahertz +gigantic +giggle +giggling +giggly +gigolo +gilled +gills +gimmick +girdle +giveaway +given +giver +giving +gizmo +gizzard +glacial +glacier +glade +gladiator +gladly +glamorous +glamour +glance +glancing +glandular +glare +glaring +glass +glaucoma +glazing +gleaming +gleeful +glider +gliding +glimmer +glimpse +glisten +glitch +glitter +glitzy +gloater +gloating +gloomily +gloomy +glorified +glorifier +glorify +glorious +glory +gloss +glove +glowing +glowworm +glucose +glue +gluten +glutinous +glutton +gnarly +gnat +goal +goatskin +goes +goggles +going +goldfish +goldmine +goldsmith +golf +goliath +gonad +gondola +gone +gong +good +gooey +goofball +goofiness +goofy +google +goon +gopher +gore +gorged +gorgeous +gory +gosling +gossip +gothic +gotten +gout +gown +grab +graceful +graceless +gracious +gradation +graded +grader +gradient +grading +gradually +graduate +graffiti +grafted +grafting +grain +granddad +grandkid +grandly +grandma +grandpa +grandson +granite +granny +granola +grant +granular +grape +graph +grapple +grappling +grasp +grass +gratified +gratify +grating +gratitude +gratuity +gravel +graveness +graves +graveyard +gravitate +gravity +gravy +gray +grazing +greasily +greedily +greedless +greedy +green +greeter +greeting +grew +greyhound +grid +grief +grievance +grieving +grievous +grill +grimace +grimacing +grime +griminess +grimy +grinch +grinning +grip +gristle +grit +groggily +groggy +groin +groom +groove +grooving +groovy +grope +ground +grouped +grout +grove +grower +growing +growl +grub +grudge +grudging +grueling +gruffly +grumble +grumbling +grumbly +grumpily +grunge +grunt +guacamole +guidable +guidance +guide +guiding +guileless +guise +gulf +gullible +gully +gulp +gumball +gumdrop +gumminess +gumming +gummy +gurgle +gurgling +guru +gush +gusto +gusty +gutless +guts +gutter +guy +guzzler +gyration +habitable +habitant +habitat +habitual +hacked +hacker +hacking +hacksaw +had +haggler +haiku +half +halogen +halt +halved +halves +hamburger +hamlet +hammock +hamper +hamster +hamstring +handbag +handball +handbook +handbrake +handcart +handclap +handclasp +handcraft +handcuff +handed +handful +handgrip +handgun +handheld +handiness +handiwork +handlebar +handled +handler +handling +handmade +handoff +handpick +handprint +handrail +handsaw +handset +handsfree +handshake +handstand +handwash +handwork +handwoven +handwrite +handyman +hangnail +hangout +hangover +hangup +hankering +hankie +hanky +haphazard +happening +happier +happiest +happily +happiness +happy +harbor +hardcopy +hardcore +hardcover +harddisk +hardened +hardener +hardening +hardhat +hardhead +hardiness +hardly +hardness +hardship +hardware +hardwired +hardwood +hardy +harmful +harmless +harmonica +harmonics +harmonize +harmony +harness +harpist +harsh +harvest +hash +hassle +haste +hastily +hastiness +hasty +hatbox +hatchback +hatchery +hatchet +hatching +hatchling +hate +hatless +hatred +haunt +haven +hazard +hazelnut +hazily +haziness +hazing +hazy +headache +headband +headboard +headcount +headdress +headed +header +headfirst +headgear +heading +headlamp +headless +headlock +headphone +headpiece +headrest +headroom +headscarf +headset +headsman +headstand +headstone +headway +headwear +heap +heat +heave +heavily +heaviness +heaving +hedge +hedging +heftiness +hefty +helium +helmet +helper +helpful +helping +helpless +helpline +hemlock +hemstitch +hence +henchman +henna +herald +herbal +herbicide +herbs +heritage +hermit +heroics +heroism +herring +herself +hertz +hesitancy +hesitant +hesitate +hexagon +hexagram +hubcap +huddle +huddling +huff +hug +hula +hulk +hull +human +humble +humbling +humbly +humid +humiliate +humility +humming +hummus +humongous +humorist +humorless +humorous +humpback +humped +humvee +hunchback +hundredth +hunger +hungrily +hungry +hunk +hunter +hunting +huntress +huntsman +hurdle +hurled +hurler +hurling +hurray +hurricane +hurried +hurry +hurt +husband +hush +husked +huskiness +hut +hybrid +hydrant +hydrated +hydration +hydrogen +hydroxide +hyperlink +hypertext +hyphen +hypnoses +hypnosis +hypnotic +hypnotism +hypnotist +hypnotize +hypocrisy +hypocrite +ibuprofen +ice +iciness +icing +icky +icon +icy +idealism +idealist +idealize +ideally +idealness +identical +identify +identity +ideology +idiocy +idiom +idly +igloo +ignition +ignore +iguana +illicitly +illusion +illusive +image +imaginary +imagines +imaging +imbecile +imitate +imitation +immature +immerse +immersion +imminent +immobile +immodest +immorally +immortal +immovable +immovably +immunity +immunize +impaired +impale +impart +impatient +impeach +impeding +impending +imperfect +imperial +impish +implant +implement +implicate +implicit +implode +implosion +implosive +imply +impolite +important +importer +impose +imposing +impotence +impotency +impotent +impound +imprecise +imprint +imprison +impromptu +improper +improve +improving +improvise +imprudent +impulse +impulsive +impure +impurity +iodine +iodize +ion +ipad +iphone +ipod +irate +irk +iron +irregular +irrigate +irritable +irritably +irritant +irritate +islamic +islamist +isolated +isolating +isolation +isotope +issue +issuing +italicize +italics +item +itinerary +itunes +ivory +ivy +jab +jackal +jacket +jackknife +jackpot +jailbird +jailbreak +jailer +jailhouse +jalapeno +jam +janitor +january +jargon +jarring +jasmine +jaundice +jaunt +java +jawed +jawless +jawline +jaws +jaybird +jaywalker +jazz +jeep +jeeringly +jellied +jelly +jersey +jester +jet +jiffy +jigsaw +jimmy +jingle +jingling +jinx +jitters +jittery +job +jockey +jockstrap +jogger +jogging +john +joining +jokester +jokingly +jolliness +jolly +jolt +jot +jovial +joyfully +joylessly +joyous +joyride +joystick +jubilance +jubilant +judge +judgingly +judicial +judiciary +judo +juggle +juggling +jugular +juice +juiciness +juicy +jujitsu +jukebox +july +jumble +jumbo +jump +junction +juncture +june +junior +juniper +junkie +junkman +junkyard +jurist +juror +jury +justice +justifier +justify +justly +justness +juvenile +kabob +kangaroo +karaoke +karate +karma +kebab +keenly +keenness +keep +keg +kelp +kennel +kept +kerchief +kerosene +kettle +kick +kiln +kilobyte +kilogram +kilometer +kilowatt +kilt +kimono +kindle +kindling +kindly +kindness +kindred +kinetic +kinfolk +king +kinship +kinsman +kinswoman +kissable +kisser +kissing +kitchen +kite +kitten +kitty +kiwi +kleenex +knapsack +knee +knelt +knickers +knoll +koala +kooky +kosher +krypton +kudos +kung +labored +laborer +laboring +laborious +labrador +ladder +ladies +ladle +ladybug +ladylike +lagged +lagging +lagoon +lair +lake +lance +landed +landfall +landfill +landing +landlady +landless +landline +landlord +landmark +landmass +landmine +landowner +landscape +landside +landslide +language +lankiness +lanky +lantern +lapdog +lapel +lapped +lapping +laptop +lard +large +lark +lash +lasso +last +latch +late +lather +latitude +latrine +latter +latticed +launch +launder +laundry +laurel +lavender +lavish +laxative +lazily +laziness +lazy +lecturer +left +legacy +legal +legend +legged +leggings +legible +legibly +legislate +lego +legroom +legume +legwarmer +legwork +lemon +lend +length +lens +lent +leotard +lesser +letdown +lethargic +lethargy +letter +lettuce +level +leverage +levers +levitate +levitator +liability +liable +liberty +librarian +library +licking +licorice +lid +life +lifter +lifting +liftoff +ligament +likely +likeness +likewise +liking +lilac +lilly +lily +limb +limeade +limelight +limes +limit +limping +limpness +line +lingo +linguini +linguist +lining +linked +linoleum +linseed +lint +lion +lip +liquefy +liqueur +liquid +lisp +list +litigate +litigator +litmus +litter +little +livable +lived +lively +liver +livestock +lividly +living +lizard +lubricant +lubricate +lucid +luckily +luckiness +luckless +lucrative +ludicrous +lugged +lukewarm +lullaby +lumber +luminance +luminous +lumpiness +lumping +lumpish +lunacy +lunar +lunchbox +luncheon +lunchroom +lunchtime +lung +lurch +lure +luridness +lurk +lushly +lushness +luster +lustfully +lustily +lustiness +lustrous +lusty +luxurious +luxury +lying +lyrically +lyricism +lyricist +lyrics +macarena +macaroni +macaw +mace +machine +machinist +magazine +magenta +maggot +magical +magician +magma +magnesium +magnetic +magnetism +magnetize +magnifier +magnify +magnitude +magnolia +mahogany +maimed +majestic +majesty +majorette +majority +makeover +maker +makeshift +making +malformed +malt +mama +mammal +mammary +mammogram +manager +managing +manatee +mandarin +mandate +mandatory +mandolin +manger +mangle +mango +mangy +manhandle +manhole +manhood +manhunt +manicotti +manicure +manifesto +manila +mankind +manlike +manliness +manly +manmade +manned +mannish +manor +manpower +mantis +mantra +manual +many +map +marathon +marauding +marbled +marbles +marbling +march +mardi +margarine +margarita +margin +marigold +marina +marine +marital +maritime +marlin +marmalade +maroon +married +marrow +marry +marshland +marshy +marsupial +marvelous +marxism +mascot +masculine +mashed +mashing +massager +masses +massive +mastiff +matador +matchbook +matchbox +matcher +matching +matchless +material +maternal +maternity +math +mating +matriarch +matrimony +matrix +matron +matted +matter +maturely +maturing +maturity +mauve +maverick +maximize +maximum +maybe +mayday +mayflower +moaner +moaning +mobile +mobility +mobilize +mobster +mocha +mocker +mockup +modified +modify +modular +modulator +module +moisten +moistness +moisture +molar +molasses +mold +molecular +molecule +molehill +mollusk +mom +monastery +monday +monetary +monetize +moneybags +moneyless +moneywise +mongoose +mongrel +monitor +monkhood +monogamy +monogram +monologue +monopoly +monorail +monotone +monotype +monoxide +monsieur +monsoon +monstrous +monthly +monument +moocher +moodiness +moody +mooing +moonbeam +mooned +moonlight +moonlike +moonlit +moonrise +moonscape +moonshine +moonstone +moonwalk +mop +morale +morality +morally +morbidity +morbidly +morphine +morphing +morse +mortality +mortally +mortician +mortified +mortify +mortuary +mosaic +mossy +most +mothball +mothproof +motion +motivate +motivator +motive +motocross +motor +motto +mountable +mountain +mounted +mounting +mourner +mournful +mouse +mousiness +moustache +mousy +mouth +movable +move +movie +moving +mower +mowing +much +muck +mud +mug +mulberry +mulch +mule +mulled +mullets +multiple +multiply +multitask +multitude +mumble +mumbling +mumbo +mummified +mummify +mummy +mumps +munchkin +mundane +municipal +muppet +mural +murkiness +murky +murmuring +muscular +museum +mushily +mushiness +mushroom +mushy +music +musket +muskiness +musky +mustang +mustard +muster +mustiness +musty +mutable +mutate +mutation +mute +mutilated +mutilator +mutiny +mutt +mutual +muzzle +myself +myspace +mystified +mystify +myth +nacho +nag +nail +name +naming +nanny +nanometer +nape +napkin +napped +napping +nappy +narrow +nastily +nastiness +national +native +nativity +natural +nature +naturist +nautical +navigate +navigator +navy +nearby +nearest +nearly +nearness +neatly +neatness +nebula +nebulizer +nectar +negate +negation +negative +neglector +negligee +negligent +negotiate +nemeses +nemesis +neon +nephew +nerd +nervous +nervy +nest +net +neurology +neuron +neurosis +neurotic +neuter +neutron +never +next +nibble +nickname +nicotine +niece +nifty +nimble +nimbly +nineteen +ninetieth +ninja +nintendo +ninth +nuclear +nuclei +nucleus +nugget +nullify +number +numbing +numbly +numbness +numeral +numerate +numerator +numeric +numerous +nuptials +nursery +nursing +nurture +nutcase +nutlike +nutmeg +nutrient +nutshell +nuttiness +nutty +nuzzle +nylon +oaf +oak +oasis +oat +obedience +obedient +obituary +object +obligate +obliged +oblivion +oblivious +oblong +obnoxious +oboe +obscure +obscurity +observant +observer +observing +obsessed +obsession +obsessive +obsolete +obstacle +obstinate +obstruct +obtain +obtrusive +obtuse +obvious +occultist +occupancy +occupant +occupier +occupy +ocean +ocelot +octagon +octane +october +octopus +ogle +oil +oink +ointment +okay +old +olive +olympics +omega +omen +ominous +omission +omit +omnivore +onboard +oncoming +ongoing +onion +online +onlooker +only +onscreen +onset +onshore +onslaught +onstage +onto +onward +onyx +oops +ooze +oozy +opacity +opal +open +operable +operate +operating +operation +operative +operator +opium +opossum +opponent +oppose +opposing +opposite +oppressed +oppressor +opt +opulently +osmosis +other +otter +ouch +ought +ounce +outage +outback +outbid +outboard +outbound +outbreak +outburst +outcast +outclass +outcome +outdated +outdoors +outer +outfield +outfit +outflank +outgoing +outgrow +outhouse +outing +outlast +outlet +outline +outlook +outlying +outmatch +outmost +outnumber +outplayed +outpost +outpour +output +outrage +outrank +outreach +outright +outscore +outsell +outshine +outshoot +outsider +outskirts +outsmart +outsource +outspoken +outtakes +outthink +outward +outweigh +outwit +oval +ovary +oven +overact +overall +overarch +overbid +overbill +overbite +overblown +overboard +overbook +overbuilt +overcast +overcoat +overcome +overcook +overcrowd +overdraft +overdrawn +overdress +overdrive +overdue +overeager +overeater +overexert +overfed +overfeed +overfill +overflow +overfull +overgrown +overhand +overhang +overhaul +overhead +overhear +overheat +overhung +overjoyed +overkill +overlabor +overlaid +overlap +overlay +overload +overlook +overlord +overlying +overnight +overpass +overpay +overplant +overplay +overpower +overprice +overrate +overreach +overreact +override +overripe +overrule +overrun +overshoot +overshot +oversight +oversized +oversleep +oversold +overspend +overstate +overstay +overstep +overstock +overstuff +oversweet +overtake +overthrow +overtime +overtly +overtone +overture +overturn +overuse +overvalue +overview +overwrite +owl +oxford +oxidant +oxidation +oxidize +oxidizing +oxygen +oxymoron +oyster +ozone +paced +pacemaker +pacific +pacifier +pacifism +pacifist +pacify +padded +padding +paddle +paddling +padlock +pagan +pager +paging +pajamas +palace +palatable +palm +palpable +palpitate +paltry +pampered +pamperer +pampers +pamphlet +panama +pancake +pancreas +panda +pandemic +pang +panhandle +panic +panning +panorama +panoramic +panther +pantomime +pantry +pants +pantyhose +paparazzi +papaya +paper +paprika +papyrus +parabola +parachute +parade +paradox +paragraph +parakeet +paralegal +paralyses +paralysis +paralyze +paramedic +parameter +paramount +parasail +parasite +parasitic +parcel +parched +parchment +pardon +parish +parka +parking +parkway +parlor +parmesan +parole +parrot +parsley +parsnip +partake +parted +parting +partition +partly +partner +partridge +party +passable +passably +passage +passcode +passenger +passerby +passing +passion +passive +passivism +passover +passport +password +pasta +pasted +pastel +pastime +pastor +pastrami +pasture +pasty +patchwork +patchy +paternal +paternity +path +patience +patient +patio +patriarch +patriot +patrol +patronage +patronize +pauper +pavement +paver +pavestone +pavilion +paving +pawing +payable +payback +paycheck +payday +payee +payer +paying +payment +payphone +payroll +pebble +pebbly +pecan +pectin +peculiar +peddling +pediatric +pedicure +pedigree +pedometer +pegboard +pelican +pellet +pelt +pelvis +penalize +penalty +pencil +pendant +pending +penholder +penknife +pennant +penniless +penny +penpal +pension +pentagon +pentagram +pep +perceive +percent +perch +percolate +perennial +perfected +perfectly +perfume +periscope +perish +perjurer +perjury +perkiness +perky +perm +peroxide +perpetual +perplexed +persecute +persevere +persuaded +persuader +pesky +peso +pessimism +pessimist +pester +pesticide +petal +petite +petition +petri +petroleum +petted +petticoat +pettiness +petty +petunia +phantom +phobia +phoenix +phonebook +phoney +phonics +phoniness +phony +phosphate +photo +phrase +phrasing +placard +placate +placidly +plank +planner +plant +plasma +plaster +plastic +plated +platform +plating +platinum +platonic +platter +platypus +plausible +plausibly +playable +playback +player +playful +playgroup +playhouse +playing +playlist +playmaker +playmate +playoff +playpen +playroom +playset +plaything +playtime +plaza +pleading +pleat +pledge +plentiful +plenty +plethora +plexiglas +pliable +plod +plop +plot +plow +ploy +pluck +plug +plunder +plunging +plural +plus +plutonium +plywood +poach +pod +poem +poet +pogo +pointed +pointer +pointing +pointless +pointy +poise +poison +poker +poking +polar +police +policy +polio +polish +politely +polka +polo +polyester +polygon +polygraph +polymer +poncho +pond +pony +popcorn +pope +poplar +popper +poppy +popsicle +populace +popular +populate +porcupine +pork +porous +porridge +portable +portal +portfolio +porthole +portion +portly +portside +poser +posh +posing +possible +possibly +possum +postage +postal +postbox +postcard +posted +poster +posting +postnasal +posture +postwar +pouch +pounce +pouncing +pound +pouring +pout +powdered +powdering +powdery +power +powwow +pox +praising +prance +prancing +pranker +prankish +prankster +prayer +praying +preacher +preaching +preachy +preamble +precinct +precise +precision +precook +precut +predator +predefine +predict +preface +prefix +preflight +preformed +pregame +pregnancy +pregnant +preheated +prelaunch +prelaw +prelude +premiere +premises +premium +prenatal +preoccupy +preorder +prepaid +prepay +preplan +preppy +preschool +prescribe +preseason +preset +preshow +president +presoak +press +presume +presuming +preteen +pretended +pretender +pretense +pretext +pretty +pretzel +prevail +prevalent +prevent +preview +previous +prewar +prewashed +prideful +pried +primal +primarily +primary +primate +primer +primp +princess +print +prior +prism +prison +prissy +pristine +privacy +private +privatize +prize +proactive +probable +probably +probation +probe +probing +probiotic +problem +procedure +process +proclaim +procreate +procurer +prodigal +prodigy +produce +product +profane +profanity +professed +professor +profile +profound +profusely +progeny +prognosis +program +progress +projector +prologue +prolonged +promenade +prominent +promoter +promotion +prompter +promptly +prone +prong +pronounce +pronto +proofing +proofread +proofs +propeller +properly +property +proponent +proposal +propose +props +prorate +protector +protegee +proton +prototype +protozoan +protract +protrude +proud +provable +proved +proven +provided +provider +providing +province +proving +provoke +provoking +provolone +prowess +prowler +prowling +proximity +proxy +prozac +prude +prudishly +prune +pruning +pry +psychic +public +publisher +pucker +pueblo +pug +pull +pulmonary +pulp +pulsate +pulse +pulverize +puma +pumice +pummel +punch +punctual +punctuate +punctured +pungent +punisher +punk +pupil +puppet +puppy +purchase +pureblood +purebred +purely +pureness +purgatory +purge +purging +purifier +purify +purist +puritan +purity +purple +purplish +purposely +purr +purse +pursuable +pursuant +pursuit +purveyor +pushcart +pushchair +pusher +pushiness +pushing +pushover +pushpin +pushup +pushy +putdown +putt +puzzle +puzzling +pyramid +pyromania +python +quack +quadrant +quail +quaintly +quake +quaking +qualified +qualifier +qualify +quality +qualm +quantum +quarrel +quarry +quartered +quarterly +quarters +quartet +quench +query +quicken +quickly +quickness +quicksand +quickstep +quiet +quill +quilt +quintet +quintuple +quirk +quit +quiver +quizzical +quotable +quotation +quote +rabid +race +racing +racism +rack +racoon +radar +radial +radiance +radiantly +radiated +radiation +radiator +radio +radish +raffle +raft +rage +ragged +raging +ragweed +raider +railcar +railing +railroad +railway +raisin +rake +raking +rally +ramble +rambling +ramp +ramrod +ranch +rancidity +random +ranged +ranger +ranging +ranked +ranking +ransack +ranting +rants +rare +rarity +rascal +rash +rasping +ravage +raven +ravine +raving +ravioli +ravishing +reabsorb +reach +reacquire +reaction +reactive +reactor +reaffirm +ream +reanalyze +reappear +reapply +reappoint +reapprove +rearrange +rearview +reason +reassign +reassure +reattach +reawake +rebalance +rebate +rebel +rebirth +reboot +reborn +rebound +rebuff +rebuild +rebuilt +reburial +rebuttal +recall +recant +recapture +recast +recede +recent +recess +recharger +recipient +recital +recite +reckless +reclaim +recliner +reclining +recluse +reclusive +recognize +recoil +recollect +recolor +reconcile +reconfirm +reconvene +recopy +record +recount +recoup +recovery +recreate +rectal +rectangle +rectified +rectify +recycled +recycler +recycling +reemerge +reenact +reenter +reentry +reexamine +referable +referee +reference +refill +refinance +refined +refinery +refining +refinish +reflected +reflector +reflex +reflux +refocus +refold +reforest +reformat +reformed +reformer +reformist +refract +refrain +refreeze +refresh +refried +refueling +refund +refurbish +refurnish +refusal +refuse +refusing +refutable +refute +regain +regalia +regally +reggae +regime +region +register +registrar +registry +regress +regretful +regroup +regular +regulate +regulator +rehab +reheat +rehire +rehydrate +reimburse +reissue +reiterate +rejoice +rejoicing +rejoin +rekindle +relapse +relapsing +relatable +related +relation +relative +relax +relay +relearn +release +relenting +reliable +reliably +reliance +reliant +relic +relieve +relieving +relight +relish +relive +reload +relocate +relock +reluctant +rely +remake +remark +remarry +rematch +remedial +remedy +remember +reminder +remindful +remission +remix +remnant +remodeler +remold +remorse +remote +removable +removal +removed +remover +removing +rename +renderer +rendering +rendition +renegade +renewable +renewably +renewal +renewed +renounce +renovate +renovator +rentable +rental +rented +renter +reoccupy +reoccur +reopen +reorder +repackage +repacking +repaint +repair +repave +repaying +repayment +repeal +repeated +repeater +repent +rephrase +replace +replay +replica +reply +reporter +repose +repossess +repost +repressed +reprimand +reprint +reprise +reproach +reprocess +reproduce +reprogram +reps +reptile +reptilian +repugnant +repulsion +repulsive +repurpose +reputable +reputably +request +require +requisite +reroute +rerun +resale +resample +rescuer +reseal +research +reselect +reseller +resemble +resend +resent +reset +reshape +reshoot +reshuffle +residence +residency +resident +residual +residue +resigned +resilient +resistant +resisting +resize +resolute +resolved +resonant +resonate +resort +resource +respect +resubmit +result +resume +resupply +resurface +resurrect +retail +retainer +retaining +retake +retaliate +retention +rethink +retinal +retired +retiree +retiring +retold +retool +retorted +retouch +retrace +retract +retrain +retread +retreat +retrial +retrieval +retriever +retry +return +retying +retype +reunion +reunite +reusable +reuse +reveal +reveler +revenge +revenue +reverb +revered +reverence +reverend +reversal +reverse +reversing +reversion +revert +revisable +revise +revision +revisit +revivable +revival +reviver +reviving +revocable +revoke +revolt +revolver +revolving +reward +rewash +rewind +rewire +reword +rework +rewrap +rewrite +rhyme +ribbon +ribcage +rice +riches +richly +richness +rickety +ricotta +riddance +ridden +ride +riding +rifling +rift +rigging +rigid +rigor +rimless +rimmed +rind +rink +rinse +rinsing +riot +ripcord +ripeness +ripening +ripping +ripple +rippling +riptide +rise +rising +risk +risotto +ritalin +ritzy +rival +riverbank +riverbed +riverboat +riverside +riveter +riveting +roamer +roaming +roast +robbing +robe +robin +robotics +robust +rockband +rocker +rocket +rockfish +rockiness +rocking +rocklike +rockslide +rockstar +rocky +rogue +roman +romp +rope +roping +roster +rosy +rotten +rotting +rotunda +roulette +rounding +roundish +roundness +roundup +roundworm +routine +routing +rover +roving +royal +rubbed +rubber +rubbing +rubble +rubdown +ruby +ruckus +rudder +rug +ruined +rule +rumble +rumbling +rummage +rumor +runaround +rundown +runner +running +runny +runt +runway +rupture +rural +ruse +rush +rust +rut +sabbath +sabotage +sacrament +sacred +sacrifice +sadden +saddlebag +saddled +saddling +sadly +sadness +safari +safeguard +safehouse +safely +safeness +saffron +saga +sage +sagging +saggy +said +saint +sake +salad +salami +salaried +salary +saline +salon +saloon +salsa +salt +salutary +salute +salvage +salvaging +salvation +same +sample +sampling +sanction +sanctity +sanctuary +sandal +sandbag +sandbank +sandbar +sandblast +sandbox +sanded +sandfish +sanding +sandlot +sandpaper +sandpit +sandstone +sandstorm +sandworm +sandy +sanitary +sanitizer +sank +santa +sapling +sappiness +sappy +sarcasm +sarcastic +sardine +sash +sasquatch +sassy +satchel +satiable +satin +satirical +satisfied +satisfy +saturate +saturday +sauciness +saucy +sauna +savage +savanna +saved +savings +savior +savor +saxophone +say +scabbed +scabby +scalded +scalding +scale +scaling +scallion +scallop +scalping +scam +scandal +scanner +scanning +scant +scapegoat +scarce +scarcity +scarecrow +scared +scarf +scarily +scariness +scarring +scary +scavenger +scenic +schedule +schematic +scheme +scheming +schilling +schnapps +scholar +science +scientist +scion +scoff +scolding +scone +scoop +scooter +scope +scorch +scorebook +scorecard +scored +scoreless +scorer +scoring +scorn +scorpion +scotch +scoundrel +scoured +scouring +scouting +scouts +scowling +scrabble +scraggly +scrambled +scrambler +scrap +scratch +scrawny +screen +scribble +scribe +scribing +scrimmage +script +scroll +scrooge +scrounger +scrubbed +scrubber +scruffy +scrunch +scrutiny +scuba +scuff +sculptor +sculpture +scurvy +scuttle +secluded +secluding +seclusion +second +secrecy +secret +sectional +sector +secular +securely +security +sedan +sedate +sedation +sedative +sediment +seduce +seducing +segment +seismic +seizing +seldom +selected +selection +selective +selector +self +seltzer +semantic +semester +semicolon +semifinal +seminar +semisoft +semisweet +senate +senator +send +senior +senorita +sensation +sensitive +sensitize +sensually +sensuous +sepia +september +septic +septum +sequel +sequence +sequester +series +sermon +serotonin +serpent +serrated +serve +service +serving +sesame +sessions +setback +setting +settle +settling +setup +sevenfold +seventeen +seventh +seventy +severity +shabby +shack +shaded +shadily +shadiness +shading +shadow +shady +shaft +shakable +shakily +shakiness +shaking +shaky +shale +shallot +shallow +shame +shampoo +shamrock +shank +shanty +shape +shaping +share +sharpener +sharper +sharpie +sharply +sharpness +shawl +sheath +shed +sheep +sheet +shelf +shell +shelter +shelve +shelving +sherry +shield +shifter +shifting +shiftless +shifty +shimmer +shimmy +shindig +shine +shingle +shininess +shining +shiny +ship +shirt +shivering +shock +shone +shoplift +shopper +shopping +shoptalk +shore +shortage +shortcake +shortcut +shorten +shorter +shorthand +shortlist +shortly +shortness +shorts +shortwave +shorty +shout +shove +showbiz +showcase +showdown +shower +showgirl +showing +showman +shown +showoff +showpiece +showplace +showroom +showy +shrank +shrapnel +shredder +shredding +shrewdly +shriek +shrill +shrimp +shrine +shrink +shrivel +shrouded +shrubbery +shrubs +shrug +shrunk +shucking +shudder +shuffle +shuffling +shun +shush +shut +shy +siamese +siberian +sibling +siding +sierra +siesta +sift +sighing +silenced +silencer +silent +silica +silicon +silk +silliness +silly +silo +silt +silver +similarly +simile +simmering +simple +simplify +simply +sincere +sincerity +singer +singing +single +singular +sinister +sinless +sinner +sinuous +sip +siren +sister +sitcom +sitter +sitting +situated +situation +sixfold +sixteen +sixth +sixties +sixtieth +sixtyfold +sizable +sizably +size +sizing +sizzle +sizzling +skater +skating +skedaddle +skeletal +skeleton +skeptic +sketch +skewed +skewer +skid +skied +skier +skies +skiing +skilled +skillet +skillful +skimmed +skimmer +skimming +skimpily +skincare +skinhead +skinless +skinning +skinny +skintight +skipper +skipping +skirmish +skirt +skittle +skydiver +skylight +skyline +skype +skyrocket +skyward +slab +slacked +slacker +slacking +slackness +slacks +slain +slam +slander +slang +slapping +slapstick +slashed +slashing +slate +slather +slaw +sled +sleek +sleep +sleet +sleeve +slept +sliceable +sliced +slicer +slicing +slick +slider +slideshow +sliding +slighted +slighting +slightly +slimness +slimy +slinging +slingshot +slinky +slip +slit +sliver +slobbery +slogan +sloped +sloping +sloppily +sloppy +slot +slouching +slouchy +sludge +slug +slum +slurp +slush +sly +small +smartly +smartness +smasher +smashing +smashup +smell +smelting +smile +smilingly +smirk +smite +smith +smitten +smock +smog +smoked +smokeless +smokiness +smoking +smoky +smolder +smooth +smother +smudge +smudgy +smuggler +smuggling +smugly +smugness +snack +snagged +snaking +snap +snare +snarl +snazzy +sneak +sneer +sneeze +sneezing +snide +sniff +snippet +snipping +snitch +snooper +snooze +snore +snoring +snorkel +snort +snout +snowbird +snowboard +snowbound +snowcap +snowdrift +snowdrop +snowfall +snowfield +snowflake +snowiness +snowless +snowman +snowplow +snowshoe +snowstorm +snowsuit +snowy +snub +snuff +snuggle +snugly +snugness +speak +spearfish +spearhead +spearman +spearmint +species +specimen +specked +speckled +specks +spectacle +spectator +spectrum +speculate +speech +speed +spellbind +speller +spelling +spendable +spender +spending +spent +spew +sphere +spherical +sphinx +spider +spied +spiffy +spill +spilt +spinach +spinal +spindle +spinner +spinning +spinout +spinster +spiny +spiral +spirited +spiritism +spirits +spiritual +splashed +splashing +splashy +splatter +spleen +splendid +splendor +splice +splicing +splinter +splotchy +splurge +spoilage +spoiled +spoiler +spoiling +spoils +spoken +spokesman +sponge +spongy +sponsor +spoof +spookily +spooky +spool +spoon +spore +sporting +sports +sporty +spotless +spotlight +spotted +spotter +spotting +spotty +spousal +spouse +spout +sprain +sprang +sprawl +spray +spree +sprig +spring +sprinkled +sprinkler +sprint +sprite +sprout +spruce +sprung +spry +spud +spur +sputter +spyglass +squabble +squad +squall +squander +squash +squatted +squatter +squatting +squeak +squealer +squealing +squeamish +squeegee +squeeze +squeezing +squid +squiggle +squiggly +squint +squire +squirt +squishier +squishy +stability +stabilize +stable +stack +stadium +staff +stage +staging +stagnant +stagnate +stainable +stained +staining +stainless +stalemate +staleness +stalling +stallion +stamina +stammer +stamp +stand +stank +staple +stapling +starboard +starch +stardom +stardust +starfish +stargazer +staring +stark +starless +starlet +starlight +starlit +starring +starry +starship +starter +starting +startle +startling +startup +starved +starving +stash +state +static +statistic +statue +stature +status +statute +statutory +staunch +stays +steadfast +steadier +steadily +steadying +steam +steed +steep +steerable +steering +steersman +stegosaur +stellar +stem +stench +stencil +step +stereo +sterile +sterility +sterilize +sterling +sternness +sternum +stew +stick +stiffen +stiffly +stiffness +stifle +stifling +stillness +stilt +stimulant +stimulate +stimuli +stimulus +stinger +stingily +stinging +stingray +stingy +stinking +stinky +stipend +stipulate +stir +stitch +stock +stoic +stoke +stole +stomp +stonewall +stoneware +stonework +stoning +stony +stood +stooge +stool +stoop +stoplight +stoppable +stoppage +stopped +stopper +stopping +stopwatch +storable +storage +storeroom +storewide +storm +stout +stove +stowaway +stowing +straddle +straggler +strained +strainer +straining +strangely +stranger +strangle +strategic +strategy +stratus +straw +stray +streak +stream +street +strength +strenuous +strep +stress +stretch +strewn +stricken +strict +stride +strife +strike +striking +strive +striving +strobe +strode +stroller +strongbox +strongly +strongman +struck +structure +strudel +struggle +strum +strung +strut +stubbed +stubble +stubbly +stubborn +stucco +stuck +student +studied +studio +study +stuffed +stuffing +stuffy +stumble +stumbling +stump +stung +stunned +stunner +stunning +stunt +stupor +sturdily +sturdy +styling +stylishly +stylist +stylized +stylus +suave +subarctic +subatomic +subdivide +subdued +subduing +subfloor +subgroup +subheader +subject +sublease +sublet +sublevel +sublime +submarine +submerge +submersed +submitter +subpanel +subpar +subplot +subprime +subscribe +subscript +subsector +subside +subsiding +subsidize +subsidy +subsoil +subsonic +substance +subsystem +subtext +subtitle +subtly +subtotal +subtract +subtype +suburb +subway +subwoofer +subzero +succulent +such +suction +sudden +sudoku +suds +sufferer +suffering +suffice +suffix +suffocate +suffrage +sugar +suggest +suing +suitable +suitably +suitcase +suitor +sulfate +sulfide +sulfite +sulfur +sulk +sullen +sulphate +sulphuric +sultry +superbowl +superglue +superhero +superior +superjet +superman +supermom +supernova +supervise +supper +supplier +supply +support +supremacy +supreme +surcharge +surely +sureness +surface +surfacing +surfboard +surfer +surgery +surgical +surging +surname +surpass +surplus +surprise +surreal +surrender +surrogate +surround +survey +survival +survive +surviving +survivor +sushi +suspect +suspend +suspense +sustained +sustainer +swab +swaddling +swagger +swampland +swan +swapping +swarm +sway +swear +sweat +sweep +swell +swept +swerve +swifter +swiftly +swiftness +swimmable +swimmer +swimming +swimsuit +swimwear +swinger +swinging +swipe +swirl +switch +swivel +swizzle +swooned +swoop +swoosh +swore +sworn +swung +sycamore +sympathy +symphonic +symphony +symptom +synapse +syndrome +synergy +synopses +synopsis +synthesis +synthetic +syrup +system +t-shirt +tabasco +tabby +tableful +tables +tablet +tableware +tabloid +tackiness +tacking +tackle +tackling +tacky +taco +tactful +tactical +tactics +tactile +tactless +tadpole +taekwondo +tag +tainted +take +taking +talcum +talisman +tall +talon +tamale +tameness +tamer +tamper +tank +tanned +tannery +tanning +tantrum +tapeless +tapered +tapering +tapestry +tapioca +tapping +taps +tarantula +target +tarmac +tarnish +tarot +tartar +tartly +tartness +task +tassel +taste +tastiness +tasting +tasty +tattered +tattle +tattling +tattoo +taunt +tavern +thank +that +thaw +theater +theatrics +thee +theft +theme +theology +theorize +thermal +thermos +thesaurus +these +thesis +thespian +thicken +thicket +thickness +thieving +thievish +thigh +thimble +thing +think +thinly +thinner +thinness +thinning +thirstily +thirsting +thirsty +thirteen +thirty +thong +thorn +those +thousand +thrash +thread +threaten +threefold +thrift +thrill +thrive +thriving +throat +throbbing +throng +throttle +throwaway +throwback +thrower +throwing +thud +thumb +thumping +thursday +thus +thwarting +thyself +tiara +tibia +tidal +tidbit +tidiness +tidings +tidy +tiger +tighten +tightly +tightness +tightrope +tightwad +tigress +tile +tiling +till +tilt +timid +timing +timothy +tinderbox +tinfoil +tingle +tingling +tingly +tinker +tinkling +tinsel +tinsmith +tint +tinwork +tiny +tipoff +tipped +tipper +tipping +tiptoeing +tiptop +tiring +tissue +trace +tracing +track +traction +tractor +trade +trading +tradition +traffic +tragedy +trailing +trailside +train +traitor +trance +tranquil +transfer +transform +translate +transpire +transport +transpose +trapdoor +trapeze +trapezoid +trapped +trapper +trapping +traps +trash +travel +traverse +travesty +tray +treachery +treading +treadmill +treason +treat +treble +tree +trekker +tremble +trembling +tremor +trench +trend +trespass +triage +trial +triangle +tribesman +tribunal +tribune +tributary +tribute +triceps +trickery +trickily +tricking +trickle +trickster +tricky +tricolor +tricycle +trident +tried +trifle +trifocals +trillion +trilogy +trimester +trimmer +trimming +trimness +trinity +trio +tripod +tripping +triumph +trivial +trodden +trolling +trombone +trophy +tropical +tropics +trouble +troubling +trough +trousers +trout +trowel +truce +truck +truffle +trump +trunks +trustable +trustee +trustful +trusting +trustless +truth +try +tubby +tubeless +tubular +tucking +tuesday +tug +tuition +tulip +tumble +tumbling +tummy +turban +turbine +turbofan +turbojet +turbulent +turf +turkey +turmoil +turret +turtle +tusk +tutor +tutu +tux +tweak +tweed +tweet +tweezers +twelve +twentieth +twenty +twerp +twice +twiddle +twiddling +twig +twilight +twine +twins +twirl +twistable +twisted +twister +twisting +twisty +twitch +twitter +tycoon +tying +tyke +udder +ultimate +ultimatum +ultra +umbilical +umbrella +umpire +unabashed +unable +unadorned +unadvised +unafraid +unaired +unaligned +unaltered +unarmored +unashamed +unaudited +unawake +unaware +unbaked +unbalance +unbeaten +unbend +unbent +unbiased +unbitten +unblended +unblessed +unblock +unbolted +unbounded +unboxed +unbraided +unbridle +unbroken +unbuckled +unbundle +unburned +unbutton +uncanny +uncapped +uncaring +uncertain +unchain +unchanged +uncharted +uncheck +uncivil +unclad +unclaimed +unclamped +unclasp +uncle +unclip +uncloak +unclog +unclothed +uncoated +uncoiled +uncolored +uncombed +uncommon +uncooked +uncork +uncorrupt +uncounted +uncouple +uncouth +uncover +uncross +uncrown +uncrushed +uncured +uncurious +uncurled +uncut +undamaged +undated +undaunted +undead +undecided +undefined +underage +underarm +undercoat +undercook +undercut +underdog +underdone +underfed +underfeed +underfoot +undergo +undergrad +underhand +underline +underling +undermine +undermost +underpaid +underpass +underpay +underrate +undertake +undertone +undertook +undertow +underuse +underwear +underwent +underwire +undesired +undiluted +undivided +undocked +undoing +undone +undrafted +undress +undrilled +undusted +undying +unearned +unearth +unease +uneasily +uneasy +uneatable +uneaten +unedited +unelected +unending +unengaged +unenvied +unequal +unethical +uneven +unexpired +unexposed +unfailing +unfair +unfasten +unfazed +unfeeling +unfiled +unfilled +unfitted +unfitting +unfixable +unfixed +unflawed +unfocused +unfold +unfounded +unframed +unfreeze +unfrosted +unfrozen +unfunded +unglazed +ungloved +unglue +ungodly +ungraded +ungreased +unguarded +unguided +unhappily +unhappy +unharmed +unhealthy +unheard +unhearing +unheated +unhelpful +unhidden +unhinge +unhitched +unholy +unhook +unicorn +unicycle +unified +unifier +uniformed +uniformly +unify +unimpeded +uninjured +uninstall +uninsured +uninvited +union +uniquely +unisexual +unison +unissued +unit +universal +universe +unjustly +unkempt +unkind +unknotted +unknowing +unknown +unlaced +unlatch +unlawful +unleaded +unlearned +unleash +unless +unleveled +unlighted +unlikable +unlimited +unlined +unlinked +unlisted +unlit +unlivable +unloaded +unloader +unlocked +unlocking +unlovable +unloved +unlovely +unloving +unluckily +unlucky +unmade +unmanaged +unmanned +unmapped +unmarked +unmasked +unmasking +unmatched +unmindful +unmixable +unmixed +unmolded +unmoral +unmovable +unmoved +unmoving +unnamable +unnamed +unnatural +unneeded +unnerve +unnerving +unnoticed +unopened +unopposed +unpack +unpadded +unpaid +unpainted +unpaired +unpaved +unpeeled +unpicked +unpiloted +unpinned +unplanned +unplanted +unpleased +unpledged +unplowed +unplug +unpopular +unproven +unquote +unranked +unrated +unraveled +unreached +unread +unreal +unreeling +unrefined +unrelated +unrented +unrest +unretired +unrevised +unrigged +unripe +unrivaled +unroasted +unrobed +unroll +unruffled +unruly +unrushed +unsaddle +unsafe +unsaid +unsalted +unsaved +unsavory +unscathed +unscented +unscrew +unsealed +unseated +unsecured +unseeing +unseemly +unseen +unselect +unselfish +unsent +unsettled +unshackle +unshaken +unshaved +unshaven +unsheathe +unshipped +unsightly +unsigned +unskilled +unsliced +unsmooth +unsnap +unsocial +unsoiled +unsold +unsolved +unsorted +unspoiled +unspoken +unstable +unstaffed +unstamped +unsteady +unsterile +unstirred +unstitch +unstopped +unstuck +unstuffed +unstylish +unsubtle +unsubtly +unsuited +unsure +unsworn +untagged +untainted +untaken +untamed +untangled +untapped +untaxed +unthawed +unthread +untidy +untie +until +untimed +untimely +untitled +untoasted +untold +untouched +untracked +untrained +untreated +untried +untrimmed +untrue +untruth +unturned +untwist +untying +unusable +unused +unusual +unvalued +unvaried +unvarying +unveiled +unveiling +unvented +unviable +unvisited +unvocal +unwanted +unwarlike +unwary +unwashed +unwatched +unweave +unwed +unwelcome +unwell +unwieldy +unwilling +unwind +unwired +unwitting +unwomanly +unworldly +unworn +unworried +unworthy +unwound +unwoven +unwrapped +unwritten +unzip +upbeat +upchuck +upcoming +upcountry +update +upfront +upgrade +upheaval +upheld +uphill +uphold +uplifted +uplifting +upload +upon +upper +upright +uprising +upriver +uproar +uproot +upscale +upside +upstage +upstairs +upstart +upstate +upstream +upstroke +upswing +uptake +uptight +uptown +upturned +upward +upwind +uranium +urban +urchin +urethane +urgency +urgent +urging +urologist +urology +usable +usage +useable +used +uselessly +user +usher +usual +utensil +utility +utilize +utmost +utopia +utter +vacancy +vacant +vacate +vacation +vagabond +vagrancy +vagrantly +vaguely +vagueness +valiant +valid +valium +valley +valuables +value +vanilla +vanish +vanity +vanquish +vantage +vaporizer +variable +variably +varied +variety +various +varmint +varnish +varsity +varying +vascular +vaseline +vastly +vastness +veal +vegan +veggie +vehicular +velcro +velocity +velvet +vendetta +vending +vendor +veneering +vengeful +venomous +ventricle +venture +venue +venus +verbalize +verbally +verbose +verdict +verify +verse +version +versus +vertebrae +vertical +vertigo +very +vessel +vest +veteran +veto +vexingly +viability +viable +vibes +vice +vicinity +victory +video +viewable +viewer +viewing +viewless +viewpoint +vigorous +village +villain +vindicate +vineyard +vintage +violate +violation +violator +violet +violin +viper +viral +virtual +virtuous +virus +visa +viscosity +viscous +viselike +visible +visibly +vision +visiting +visitor +visor +vista +vitality +vitalize +vitally +vitamins +vivacious +vividly +vividness +vixen +vocalist +vocalize +vocally +vocation +voice +voicing +void +volatile +volley +voltage +volumes +voter +voting +voucher +vowed +vowel +voyage +wackiness +wad +wafer +waffle +waged +wager +wages +waggle +wagon +wake +waking +walk +walmart +walnut +walrus +waltz +wand +wannabe +wanted +wanting +wasabi +washable +washbasin +washboard +washbowl +washcloth +washday +washed +washer +washhouse +washing +washout +washroom +washstand +washtub +wasp +wasting +watch +water +waviness +waving +wavy +whacking +whacky +wham +wharf +wheat +whenever +whiff +whimsical +whinny +whiny +whisking +whoever +whole +whomever +whoopee +whooping +whoops +why +wick +widely +widen +widget +widow +width +wieldable +wielder +wife +wifi +wikipedia +wildcard +wildcat +wilder +wildfire +wildfowl +wildland +wildlife +wildly +wildness +willed +willfully +willing +willow +willpower +wilt +wimp +wince +wincing +wind +wing +winking +winner +winnings +winter +wipe +wired +wireless +wiring +wiry +wisdom +wise +wish +wisplike +wispy +wistful +wizard +wobble +wobbling +wobbly +wok +wolf +wolverine +womanhood +womankind +womanless +womanlike +womanly +womb +woof +wooing +wool +woozy +word +work +worried +worrier +worrisome +worry +worsening +worshiper +worst +wound +woven +wow +wrangle +wrath +wreath +wreckage +wrecker +wrecking +wrench +wriggle +wriggly +wrinkle +wrinkly +wrist +writing +written +wrongdoer +wronged +wrongful +wrongly +wrongness +wrought +xbox +xerox +yahoo +yam +yanking +yapping +yard +yarn +yeah +yearbook +yearling +yearly +yearning +yeast +yelling +yelp +yen +yesterday +yiddish +yield +yin +yippee +yo-yo +yodel +yoga +yogurt +yonder +yoyo +yummy +zap +zealous +zebra +zen +zeppelin +zero +zestfully +zesty +zigzagged +zipfile +zipping +zippy +zips +zit +zodiac +zombie +zone +zoning +zookeeper +zoologist +zoology +zoom diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 6648fef4e..088087804 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -83,6 +83,19 @@ end end + describe 'check_in_code' do + it 'generates a check_in_code on create' do + event = Fabricate(:event) + expect(event.check_in_code).to be_present + expect(event.check_in_code.split('-').length).to eq(3) + end + + it 'generates a unique check_in_code' do + codes = Array.new(3) { Fabricate(:event).check_in_code } + expect(codes.uniq.length).to eq(3) + end + end + describe '#verified_students' do it 'returns all students who have verified their attendance' do event = Fabricate(:event) diff --git a/spec/models/workshop_spec.rb b/spec/models/workshop_spec.rb index cb3e5d087..32f9e305e 100644 --- a/spec/models/workshop_spec.rb +++ b/spec/models/workshop_spec.rb @@ -321,4 +321,17 @@ expect(workshop.invitable_yet?).to be false end end + + describe 'check_in_code' do + it 'generates a check_in_code on create' do + workshop = Fabricate(:workshop) + expect(workshop.check_in_code).to be_present + expect(workshop.check_in_code.split('-').length).to eq(3) + end + + it 'generates a unique check_in_code' do + codes = Array.new(3) { Fabricate(:workshop).check_in_code } + expect(codes.uniq.length).to eq(3) + end + end end From 08460934b4f103afb55a067d3eb56ac168f9b259 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sun, 9 Aug 2026 19:42:23 +0200 Subject: [PATCH 3/4] feat(admin): add admin check-in page and PDF download Adds admin-only check-in instructions and a landscape QR-code PDF for events and workshops. Includes the prawn/pdf dependencies, word list, codebar logo asset, and route helpers. --- .rubocop_todo.yml | 14 ++++ Gemfile | 3 + Gemfile.lock | 25 +++++++ app/assets/images/check_in/logo.svg | 52 ++++++++++++++ app/controllers/admin/check_ins_controller.rb | 31 +++++++++ app/services/check_in_pdf.rb | 68 +++++++++++++++++++ app/views/admin/check_ins/show.html.haml | 38 +++++++++++ app/views/admin/events/show.html.haml | 3 + app/views/admin/workshops/show.html.haml | 3 + config/environments/development.rb | 2 + config/environments/production.rb | 4 ++ config/environments/test.rb | 3 + config/initializers/prawn_svg.rb | 5 ++ config/routes.rb | 16 +++++ .../admin/check_ins_controller_spec.rb | 43 ++++++++++++ spec/services/check_in_pdf_spec.rb | 18 +++++ 16 files changed, 328 insertions(+) create mode 100644 app/assets/images/check_in/logo.svg create mode 100644 app/controllers/admin/check_ins_controller.rb create mode 100644 app/services/check_in_pdf.rb create mode 100644 app/views/admin/check_ins/show.html.haml create mode 100644 config/initializers/prawn_svg.rb create mode 100644 spec/controllers/admin/check_ins_controller_spec.rb create mode 100644 spec/services/check_in_pdf_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6b9cffc92..01ccf11a0 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -35,6 +35,8 @@ Metrics/AbcSize: - 'lib/services/event_calendar.rb' - 'lib/tasks/setup.rake' - 'spec/support/select_from_tom_select.rb' + - 'app/services/check_in_pdf.rb' + - 'app/controllers/check_ins_controller.rb' # Offense count: 8 # Configuration parameters: CountComments, Max, CountAsOne. @@ -48,6 +50,7 @@ Metrics/ClassLength: - 'app/services/invitation_manager.rb' - 'lib/omniauth/strategies/codebar.rb' - 'lib/tasks/setup.rake' + - 'app/controllers/check_ins_controller.rb' # Offense count: 8 # Configuration parameters: AllowedMethods, AllowedPatterns, Max. @@ -60,6 +63,8 @@ Metrics/CyclomaticComplexity: - 'app/services/invitation_manager.rb' - 'lib/flodesk.rb' - 'lib/omniauth/strategies/codebar.rb' + - 'app/services/check_in_pdf.rb' + - 'app/controllers/check_ins_controller.rb' # Offense count: 40 # Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. @@ -90,6 +95,9 @@ Metrics/MethodLength: - 'lib/flodesk.rb' - 'lib/omniauth/strategies/codebar.rb' - 'lib/tasks/setup.rake' + - 'app/controllers/admin/check_ins_controller.rb' + - 'app/services/check_in_pdf.rb' + - 'app/controllers/check_ins_controller.rb' # Offense count: 7 # Configuration parameters: AllowedMethods, AllowedPatterns, Max. @@ -101,6 +109,7 @@ Metrics/PerceivedComplexity: - 'app/controllers/workshop_invitation_controller.rb' - 'app/services/invitation_manager.rb' - 'lib/omniauth/strategies/codebar.rb' + - 'app/controllers/check_ins_controller.rb' # Offense count: 5 Rails/HasAndBelongsToMany: @@ -123,3 +132,8 @@ Rails/HasManyOrHasOneDependent: - 'app/models/sponsor.rb' - 'app/models/workshop.rb' - 'app/models/workshop_invitation.rb' + +# Offense count: 1 +Metrics/BlockLength: + Exclude: + - 'app/services/check_in_pdf.rb' diff --git a/Gemfile b/Gemfile index 48491e265..f10066ac3 100644 --- a/Gemfile +++ b/Gemfile @@ -135,6 +135,9 @@ end gem 'rollbar' gem 'scout_apm' +gem 'rqrcode' +gem 'prawn' +gem 'prawn-svg', '~> 0.35' gem 'carrierwave-aws', '~> 1.6' gem 'sitemap_generator', '~> 7.1' diff --git a/Gemfile.lock b/Gemfile.lock index cd5a28aa0..659e53908 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,6 +149,7 @@ GEM carrierwave (>= 2.0, < 4) childprocess (5.1.0) logger (~> 1.5) + chunky_png (1.4.0) cocoon (1.2.15) coderay (1.1.3) coffee-script (2.4.1) @@ -355,6 +356,7 @@ GEM parser (3.3.12.0) ast (~> 2.4.1) racc + pdf-core (0.9.0) pg (1.6.3-aarch64-linux) pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-darwin) @@ -368,6 +370,14 @@ GEM popper_js (2.11.8) pp (0.6.4) prettyprint + prawn (2.4.0) + pdf-core (~> 0.9.0) + ttfunk (~> 1.7) + prawn-svg (0.40.3) + css_parser (>= 1.17.1, < 4) + matrix (~> 0.4.2) + prawn (>= 0.11.1, < 3) + rexml (>= 3.4.2, < 4) premailer (1.29.0) addressable css_parser (>= 1.19.0) @@ -472,6 +482,10 @@ GEM rolify (6.0.1) rollbar (3.8.0) rouge (4.1.3) + rqrcode (3.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.1.0) rspec-collection_matchers (1.2.1) rspec-expectations (>= 2.99.0.beta1) rspec-core (3.13.6) @@ -582,6 +596,7 @@ GEM tilt (2.8.0) timeout (0.6.1) tsort (0.2.0) + ttfunk (1.7.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -677,6 +692,8 @@ DEPENDENCIES parallel_tests pg pickadate-rails + prawn + prawn-svg (~> 0.35) premailer-rails pry-byebug pry-rails @@ -692,6 +709,7 @@ DEPENDENCIES reline rolify rollbar + rqrcode rspec-collection_matchers rspec-rails rubocop @@ -760,6 +778,7 @@ CHECKSUMS carrierwave (3.1.3) sha256=b99324c5ea63c55ce0776bc0e997c1c70cdd3a490a7458d23a8978930c76541a carrierwave-aws (1.6.1) sha256=ad9eb8cc677c0b9371bd3534b53689ca731ddc6d04fe764e131e298f36adb13b childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe cocoon (1.2.15) sha256=d08f14e69653287d7a060ee43389b8c824e55191dffbca0c5c586f38ef491f0d coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b coffee-script (2.4.1) sha256=82fe281e11b93c8117b98c5ea8063e71741870f1c4fbb27177d7d6333dd38765 @@ -857,6 +876,7 @@ CHECKSUMS parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pdf-core (0.9.0) sha256=4f368b2f12b57ec979872d4bf4bd1a67e8648e0c81ab89801431d2fc89f4e0bb pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 @@ -865,6 +885,8 @@ CHECKSUMS playwright-ruby-client (1.61.0) sha256=1f5c5f0307a2f6bd70b4fa797020a30eef5680caf8d5bd9ccc8d3937f6666e08 popper_js (2.11.8) sha256=f4b0be717fc0d50bdb3dbbc55788525a9e0e8f640b76c9971fc34ee609eadbd2 pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prawn (2.4.0) sha256=82062744f7126c2d77501da253a154271790254dfa8c309b8e52e79bc5de2abd + prawn-svg (0.40.3) sha256=26237d3a7268143b5ffc6c1354ab93d0a4b81366573c749b6316e6f6f7733c0c premailer (1.29.0) sha256=015f30c520701f3d47fd898886a3eaf4e5171efcc5fd239b872efd1ba61d3da0 premailer-rails (1.12.0) sha256=c13815d161b9bc7f7d3d81396b0bb0a61a90fa9bd89931548bf4e537c7710400 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 @@ -902,6 +924,8 @@ CHECKSUMS rolify (6.0.1) sha256=fe45fbf70c4033ccada6f9ccc02b946eeaad044ea5d3e38546ae408d3adeb5b6 rollbar (3.8.0) sha256=655d2783c84c854634f4a1045f34ff8d390596ee4c481f01fe1b214423047a3e rouge (4.1.3) sha256=9c8663db26e05e52b3b0286daacae73ebb361c1bd31d7febd8c57087faa0b9a5 + rqrcode (3.2.0) sha256=64c1494ca6bb67d731330f38b50e3fd09eeab4f5dcd04b608e21218d1d0b9542 + rqrcode_core (2.1.0) sha256=f303b85df89c1b8fc5ee8dc19808c9dc4330e6329b660d99d4a8cbb36ca13051 rspec-collection_matchers (1.2.1) sha256=ff7626d2bbf16ac237fbb46439694c78b7d9c6dc49c4b216df437968133b56a2 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 @@ -943,6 +967,7 @@ CHECKSUMS tilt (2.8.0) sha256=ba472eb2716fe1e04112d6d219a9dae938ec09a6a1e2ad3ecc7922e79bde3721 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + ttfunk (1.7.0) sha256=2370ba484b1891c70bdcafd3448cfd82a32dd794802d81d720a64c15d3ef2a96 turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b tzinfo-data (1.2026.3) sha256=478fbc5356f13c1004cf8372b1336f3dad4055c96340fc4c881a3738da8cf7f9 diff --git a/app/assets/images/check_in/logo.svg b/app/assets/images/check_in/logo.svg new file mode 100644 index 000000000..b1ea23644 --- /dev/null +++ b/app/assets/images/check_in/logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/controllers/admin/check_ins_controller.rb b/app/controllers/admin/check_ins_controller.rb new file mode 100644 index 000000000..8a9f13ee3 --- /dev/null +++ b/app/controllers/admin/check_ins_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Admin::CheckInsController < Admin::ApplicationController + before_action :load_parent + + def show + authorize @parent + @parent.with_lock { @parent.generate_check_in_code! if @parent.check_in_code.blank? } + + respond_to do |format| + format.html + format.pdf do + pdf = CheckInPdf.new(@parent).render + send_data pdf, + filename: "check-in-#{@parent.to_param}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + end + end + + private + + def load_parent + if params[:event_id] + @parent = Event.find_by!(slug: params[:event_id]) + elsif params[:workshop_id] + @parent = Workshop.find(params[:workshop_id]) + end + end +end diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb new file mode 100644 index 000000000..07bcb1ed3 --- /dev/null +++ b/app/services/check_in_pdf.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class CheckInPdf + def initialize(parent) + @parent = parent + end + + def render + Prawn::Document.new(page_layout: :landscape, page_size: 'A4', margin: 10) do |pdf| + draw_logo(pdf) + pdf.move_down 24 + + pdf.text @parent.to_s, size: 28, style: :bold + pdf.move_down 4 + pdf.text formatted_date, size: 16, color: '555555' + pdf.move_down 8 + + if @parent.respond_to?(:venue) && @parent.venue.present? + venue = @parent.venue + pdf.text venue.name, size: 14, color: '555555' + if venue.respond_to?(:address) && venue.address.present? + pdf.text AddressPresenter.new(venue.address).to_s, size: 12, color: '777777' + end + pdf.move_down 4 + end + + if @parent.respond_to?(:sponsors) && @parent.sponsors.any? + pdf.text "Sponsored by: #{@parent.sponsors.map(&:name).join(', ')}", size: 12, color: '777777' + pdf.move_down 8 + end + + qrcode = RQRCode::QRCode.new(@parent.check_in_url) + png = qrcode.as_png(module_size: 6) + qr_width = 160 + + pdf.image StringIO.new(png.to_blob), + width: qr_width, + position: :center + pdf.move_down 4 + pdf.text 'Scan to check in', size: 11, align: :center, color: '999999' + pdf.move_down 2 + pdf.text @parent.check_in_url, + size: 10, align: :center, color: '999999' + end.render + end + + private + + def draw_logo(pdf) + svg_path = Rails.root.join('app/assets/images/check_in/logo.svg') + + if File.exist?(svg_path) + svg_content = File.read(svg_path) + mark_size = 120 + x_start = (pdf.bounds.width - mark_size) / 2.0 + pdf.bounding_box([x_start, pdf.cursor], width: mark_size, height: mark_size) do + pdf.svg svg_content, width: mark_size, at: [0, 0], enable_web_requests: false + end + else + pdf.text 'codebar', size: 24, style: :bold, align: :center + end + end + + def formatted_date + dt = @parent.date_and_time + dt.strftime('%A, %B %d, %Y at %H:%M') + end +end diff --git a/app/views/admin/check_ins/show.html.haml b/app/views/admin/check_ins/show.html.haml new file mode 100644 index 000000000..5c67b09ab --- /dev/null +++ b/app/views/admin/check_ins/show.html.haml @@ -0,0 +1,38 @@ +-# app/views/admin/check_ins/show.html.haml + +.container.py-4.py-lg-5 + %h2 Check-in for #{@parent.to_s} + + = link_to url_for([:admin, @parent]), class: 'btn btn-outline-secondary mb-3' do + %i.fas.fa-arrow-left + Back to #{@parent.class.model_name.human} + + .alert.alert-info + %p.mb-0 + Let attendees mark themselves as attended by scanning the QR code on the PDF. + Once scanned, they sign in with GitHub and confirm their role. + You'll see their attendance update in the event admin page. + + .card.mb-4 + .card-body + %h5.card-title Check-in URL + %p.lead.mb-1= @parent.check_in_code + %p.text-muted.small= @parent.check_in_url + %p.text-muted.small Share this URL with anyone who can't scan the QR code. + + .card.mb-4 + .card-body + %h5.card-title How to use + %ol + %li Download the PDF below. + %li Print it or display it on a screen at the venue entrance. + %li Attendees scan the QR code with their phone. + %li They sign in with GitHub and select their role (Student/Coach). + %li + They're checked in! You can see attendance on the + = link_to "#{@parent.class.model_name.human} admin page", url_for([:admin, @parent]) + + = link_to url_for([:admin, @parent, :check_in, { format: :pdf }]), + class: 'btn btn-primary btn-lg' do + %i.fas.fa-download + Download PDF diff --git a/app/views/admin/events/show.html.haml b/app/views/admin/events/show.html.haml index 479c27243..557c2ea4c 100644 --- a/app/views/admin/events/show.html.haml +++ b/app/views/admin/events/show.html.haml @@ -20,6 +20,9 @@ = link_to admin_event_path(@event, format: 'csv'), class: 'btn btn-primary py-3 rounded-0', title: 'CSV for labels' do %i.fas.fa-users %label.text-white Labels + = link_to admin_event_check_in_path(@event), class: 'btn btn-primary py-3' do + %i.fas.fa-qrcode + %label.text-white Check-in .container.py-4.py-lg-5 .row.mb-4 diff --git a/app/views/admin/workshops/show.html.haml b/app/views/admin/workshops/show.html.haml index 115ccd657..1df504455 100644 --- a/app/views/admin/workshops/show.html.haml +++ b/app/views/admin/workshops/show.html.haml @@ -27,6 +27,9 @@ = link_to admin_workshop_attendees_emails_path(@workshop, format: 'text'), title: 'Attendee emails', class: 'btn btn-primary py-3' do %i.fas.fa-at %label.text-white Emails + = link_to admin_workshop_check_in_path(@workshop), class: 'btn btn-primary py-3' do + %i.fas.fa-qrcode + %label.text-white Check-in = link_to admin_workshop_path(@workshop), method: :delete, data: { confirm: 'Are you sure you want to delete this workshop?' }, title: 'Destroy workshop', class: 'btn btn-primary py-3 rounded-0' do %i.fas.fa-times %label.text-white Destroy diff --git a/config/environments/development.rb b/config/environments/development.rb index 3f030ff3d..a0e3472c6 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -93,5 +93,7 @@ Bullet.bullet_logger = true Bullet.console = true Bullet.rails_logger = true + + Rails.application.routes.default_url_options = { host: "localhost", port: 3000 } end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 6b16c5d3d..88a103058 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -68,6 +68,10 @@ # Set host to be used by links generated in mailer templates. config.action_mailer.default_url_options = { host: 'codebar.io' } + config.after_initialize do + Rails.application.routes.default_url_options = { host: 'codebar.io' } + end + # Host for absolute asset URLs in emails. Override for staging via ASSET_HOST env var. config.action_mailer.asset_host = ENV.fetch('ASSET_HOST', 'https://codebar.io') diff --git a/config/environments/test.rb b/config/environments/test.rb index c66414de3..9b2aba52d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -61,5 +61,8 @@ Bullet.enable = true Bullet.bullet_logger = true Bullet.raise = false # raise an error if n+1 query occurs + + # Ensure route url_helpers (e.g. CheckInCode#check_in_url) work in service specs + Rails.application.routes.default_url_options = { host: 'localhost:3000' } end end diff --git a/config/initializers/prawn_svg.rb b/config/initializers/prawn_svg.rb new file mode 100644 index 000000000..d03edc56f --- /dev/null +++ b/config/initializers/prawn_svg.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + Prawn::Document.include(Prawn::SVG::Extension) +end diff --git a/config/routes.rb b/config/routes.rb index a7f8ded7c..5e3bbc5f8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -163,6 +163,14 @@ get 'results' end end + + resources :events, only: [] do + resource :check_in, only: [:show], controller: "check_ins" + end + + resources :workshops, only: [] do + resource :check_in, only: [:show], controller: "check_ins" + end end get '/login', to: 'auth_services#new' @@ -175,6 +183,14 @@ resources :donations, only: %i[new] resources :payments, only: %i[new create] + # Public check-in routes + get "check-in/e/:code" => "check_ins#new", as: :check_in_e + post "check-in/e/:code" => "check_ins#create" + get "check-in/e/:code/confirm" => "check_ins#confirm", as: :check_in_e_confirm + get "check-in/w/:code" => "check_ins#new", as: :check_in_w + post "check-in/w/:code" => "check_ins#create" + get "check-in/w/:code/confirm" => "check_ins#confirm", as: :check_in_w_confirm + get 'cookie-policy' => 'pages#show', id: 'cookie-policy' get 'privacy-policy' => 'pages#show', id: 'privacy-policy' get 'breach-code-of-conduct' => 'pages#show', id: 'breach-code-of-conduct' diff --git a/spec/controllers/admin/check_ins_controller_spec.rb b/spec/controllers/admin/check_ins_controller_spec.rb new file mode 100644 index 000000000..dd07a8b01 --- /dev/null +++ b/spec/controllers/admin/check_ins_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::CheckInsController do + let(:admin) { Fabricate(:member) } + let(:event) { Fabricate(:event) } + let(:workshop) { Fabricate(:workshop) } + + before { login_as_admin(admin) } + + describe 'GET show' do + it 'renders the instructions page for an event' do + get :show, params: { event_id: event.slug } + expect(response).to be_successful + end + + it 'renders the instructions page for a workshop' do + get :show, params: { workshop_id: workshop.id } + expect(response).to be_successful + end + + it 'generates a check-in code when missing' do + event.update_column(:check_in_code, nil) + expect(event.reload.check_in_code).to be_blank + get :show, params: { event_id: event.slug } + expect(event.reload.check_in_code).to be_present + expect(response).to be_successful + end + + it 'returns PDF for .pdf format for an event' do + get :show, params: { event_id: event.slug, format: :pdf } + expect(response.content_type).to eq('application/pdf') + expect(response.body).to start_with('%PDF') + end + + it 'returns PDF for .pdf format for a workshop' do + get :show, params: { workshop_id: workshop.id, format: :pdf } + expect(response.content_type).to eq('application/pdf') + expect(response.body).to start_with('%PDF') + end + end +end diff --git a/spec/services/check_in_pdf_spec.rb b/spec/services/check_in_pdf_spec.rb new file mode 100644 index 000000000..92d254c6a --- /dev/null +++ b/spec/services/check_in_pdf_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CheckInPdf do + subject(:pdf) { described_class.new(event) } + + let(:event) { Fabricate(:event) } + + it 'generates a PDF' do + output = pdf.render + expect(output).to start_with('%PDF') + end + + it 'renders without error' do + expect { pdf.render }.not_to raise_error + end +end From 309a15f7c3cfba515accdc9a9f32215f595881c5 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sun, 9 Aug 2026 19:43:05 +0200 Subject: [PATCH 4/4] feat(check-in): public self-check-in flow Members can scan the QR code, sign in, select Student/Coach, and check in. Enforces the event-time window, capacity, waiting list, and a single role per member. --- app/controllers/check_ins_controller.rb | 148 ++++++++++++++++++ app/views/check_ins/confirm.html.haml | 12 ++ app/views/check_ins/new.html.haml | 46 ++++++ spec/controllers/check_ins_controller_spec.rb | 112 +++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 app/controllers/check_ins_controller.rb create mode 100644 app/views/check_ins/confirm.html.haml create mode 100644 app/views/check_ins/new.html.haml create mode 100644 spec/controllers/check_ins_controller_spec.rb diff --git a/app/controllers/check_ins_controller.rb b/app/controllers/check_ins_controller.rb new file mode 100644 index 000000000..61f2efc81 --- /dev/null +++ b/app/controllers/check_ins_controller.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +class CheckInsController < ApplicationController + before_action :load_parent + before_action :store_referer_path, only: [:new] + before_action :authenticate_member!, only: %i[new create confirm] + + def new + @invitation = find_invitation + @suggested_role = infer_role + @already_checked_in = already_checked_in?(@invitation) + end + + def create + role = permitted_role + existing = find_invitation + + if existing + return redirect_to check_in_confirm_path if already_checked_in?(existing) + return redirect_to check_in_new_path, alert: mismatch_role_message(existing.role) if existing.role != role + end + + unless @parent.check_in_open? + return redirect_to check_in_new_path, alert: 'Check-in is not currently open.' + end + + if @parent.respond_to?(:waitlisted?) && @parent.waitlisted?(current_user) + return redirect_to check_in_new_path, alert: 'You are on the waiting list and cannot check in.' + end + + invitation = existing || find_or_create_invitation(role) + + unless @parent.spaces_available_for?(role) || invitation.attending? + return redirect_to check_in_new_path, alert: "There are no #{role} spaces left." + end + + mark_attended(invitation) + redirect_to check_in_confirm_path + rescue ActionController::ParameterMissing + redirect_to check_in_new_path, alert: 'Please select a valid role (Student or Coach).' + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e + redirect_to check_in_new_path, alert: e.message + end + + def confirm + @invitation = find_invitation + end + + private + + def load_parent + @parent = Event.find_by(check_in_code: params[:code]) || + Workshop.find_by(check_in_code: params[:code]) + raise ActiveRecord::RecordNotFound unless @parent + end + + helper_method :check_in_new_path, :check_in_submit_path, :check_in_confirm_path + + def check_in_new_path + if @parent.is_a?(Event) + check_in_e_path(code: @parent.check_in_code) + else + check_in_w_path(code: @parent.check_in_code) + end + end + + def check_in_submit_path + if @parent.is_a?(Event) + check_in_e_path(code: @parent.check_in_code) + else + check_in_w_path(code: @parent.check_in_code) + end + end + + def check_in_confirm_path + if @parent.is_a?(Event) + check_in_e_confirm_path(code: @parent.check_in_code) + else + check_in_w_confirm_path(code: @parent.check_in_code) + end + end + + def store_referer_path + session[:referer_path] = request.path unless logged_in? + end + + def already_checked_in?(invitation) + return false unless invitation + + if @parent.is_a?(Event) + invitation.verified? + else + invitation.attended? + end + end + + def mismatch_role_message(role) + "You are already registered as a #{role}. Please select that role." + end + + def find_invitation + if @parent.is_a?(Event) + Invitation.find_by(event: @parent, member: current_user) + else + WorkshopInvitation.find_by(workshop: @parent, member: current_user) + end + end + + def find_or_create_invitation(role) + if @parent.is_a?(Event) + Invitation.create_or_find_by(event: @parent, member: current_user, role: role) + else + WorkshopInvitation.create_or_find_by(workshop: @parent, member: current_user, role: role) + end + end + + def mark_attended(invitation) + attrs = { attending: true, source: InvitationConcerns::SOURCE_CHECK_IN } + if @parent.is_a?(Event) + attrs[:verified] = true + else + attrs[:attended] = true + attrs[:automated_rsvp] = true + end + invitation.update!(attrs) + end + + def permitted_role + role = params.expect(:role) + return role if %w[Student Coach].include?(role) + + raise ActionController::ParameterMissing, :role + end + + def infer_role + return @invitation.role if @invitation.present? + + groups = current_user.groups + student = groups.students.any? + coach = groups.coaches.any? + + if student && !coach + 'Student' + elsif coach && !student + 'Coach' + end + end +end diff --git a/app/views/check_ins/confirm.html.haml b/app/views/check_ins/confirm.html.haml new file mode 100644 index 000000000..cbca3e36d --- /dev/null +++ b/app/views/check_ins/confirm.html.haml @@ -0,0 +1,12 @@ +-# app/views/check_ins/confirm.html.haml + +.container.py-4.py-lg-5 + .text-center.py-5 + %h1.text-success ✓ You're checked in! + %p.lead As a #{@invitation.role} at #{@parent.to_s} + %p.text-muted + - if @parent.respond_to?(:ends_at) && @parent.ends_at.present? + = "#{@parent.date_and_time.strftime('%A, %B %d, %Y from %H:%M')} until #{@parent.ends_at.strftime('%H:%M')}" + - else + = @parent.date_and_time.strftime('%A, %B %d, %Y at %H:%M') + = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-4" diff --git a/app/views/check_ins/new.html.haml b/app/views/check_ins/new.html.haml new file mode 100644 index 000000000..af36b0a01 --- /dev/null +++ b/app/views/check_ins/new.html.haml @@ -0,0 +1,46 @@ +-# app/views/check_ins/new.html.haml + +.container.py-4.py-lg-5 + %h1= @parent.to_s + %p.lead + - if @parent.respond_to?(:ends_at) && @parent.ends_at.present? + = "#{@parent.date_and_time.strftime('%A, %B %d, %Y from %H:%M')} until #{@parent.ends_at.strftime('%H:%M')}" + - else + = @parent.date_and_time.strftime('%A, %B %d, %Y at %H:%M') + + - if @parent.respond_to?(:venue) && @parent.venue.present? + %p + %strong= @parent.venue.name + - if @parent.respond_to?(:chapters) && @parent.chapters.any? + %p.text-muted Organized by #{@parent.chapters.map(&:name).to_sentence} + - elsif @parent.respond_to?(:chapter) && @parent.chapter.present? + %p.text-muted Organized by #{@parent.chapter.name} + + - if @already_checked_in + .text-center.py-4 + %h2.text-success ✓ Already checked in! + %p.lead As a #{@invitation.role} + = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-3" + - else + %h3 Welcome, #{current_user.name}! + + - if @invitation.present? + %p You were invited as a #{@invitation.role}. Confirm below. + + - if @suggested_role + %p.text-muted We think you're here as a #{@suggested_role}. + + .row.mt-4 + .col-6 + = form_tag check_in_submit_path, method: :post do + = hidden_field_tag :role, "Student" + = submit_tag "I'm a Student", + class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Student"} #{"btn-outline-secondary" unless @suggested_role == "Student"}", + style: "width: 100%; min-height: 80px; font-size: 1.5rem;" + + .col-6 + = form_tag check_in_submit_path, method: :post do + = hidden_field_tag :role, "Coach" + = submit_tag "I'm a Coach", + class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Coach"} #{"btn-outline-secondary" unless @suggested_role == "Coach"}", + style: "width: 100%; min-height: 80px; font-size: 1.5rem;" diff --git a/spec/controllers/check_ins_controller_spec.rb b/spec/controllers/check_ins_controller_spec.rb new file mode 100644 index 000000000..2bf85549c --- /dev/null +++ b/spec/controllers/check_ins_controller_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CheckInsController do + let(:member) { Fabricate(:member) } + let(:event) do + Fabricate(:event, + date_and_time: Time.zone.now - 30.minutes, + ends_at: Time.zone.now + 30.minutes) + end + let(:future_event) { Fabricate(:event) } + + describe 'GET new' do + it 'renders the role selection page when logged in' do + login(member) + get :new, params: { code: event.check_in_code } + expect(response).to be_successful + end + + it 'redirects to auth when not logged in' do + get :new, params: { code: event.check_in_code } + expect(response).to redirect_to('/auth/codebar') + end + end + + describe 'POST create' do + it 'redirects to auth when not logged in' do + post :create, params: { code: event.check_in_code, role: 'Student' } + expect(response).to redirect_to('/auth/codebar') + end + + context 'when logged in' do + before { login(member) } + + it 'creates an invitation with source=check_in' do + post :create, params: { code: event.check_in_code, role: 'Student' } + invitation = Invitation.find_by(event: event, member: member) + expect(invitation.source).to eq(InvitationConcerns::SOURCE_CHECK_IN) + expect(invitation.attending).to be true + expect(invitation.verified).to be true + expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) + end + + it 'rejects check-in when the event is not open' do + post :create, params: { code: future_event.check_in_code, role: 'Student' } + expect(response).to redirect_to(check_in_e_path(code: future_event.check_in_code)) + expect(flash[:alert]).to eq('Check-in is not currently open.') + end + + it 'rejects an invalid role' do + post :create, params: { code: event.check_in_code, role: 'Hacker' } + expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) + expect(flash[:alert]).to be_present + end + + it 'ignores unpermitted parameters' do + post :create, params: { code: event.check_in_code, role: 'Student', hacker_field: 'malicious' } + invitation = Invitation.find_by(event: event, member: member) + expect(invitation.source).to eq(InvitationConcerns::SOURCE_CHECK_IN) + expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) + end + + it 'redirects to confirm when already checked in' do + invitation = Fabricate(:invitation, event: event, member: member, role: 'Student', attending: true, verified: true) + post :create, params: { code: event.check_in_code, role: 'Student' } + expect(response).to redirect_to(check_in_e_confirm_path(code: event.check_in_code)) + expect(Invitation.find(invitation.id).verified).to be true + end + + it 'rejects selecting a different role than the existing invitation' do + Fabricate(:invitation, event: event, member: member, role: 'Student') + post :create, params: { code: event.check_in_code, role: 'Coach' } + expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) + expect(flash[:alert]).to include('Student') + end + + it 'rejects check-in when the role is at capacity' do + event.update!(student_spaces: 1) + Fabricate(:invitation, event: event, member: Fabricate(:member), role: 'Student', + attending: true, verified: true) + post :create, params: { code: event.check_in_code, role: 'Student' } + expect(response).to redirect_to(check_in_e_path(code: event.check_in_code)) + expect(flash[:alert]).to include('no Student spaces left') + end + + it 'checks in for a workshop and marks attended' do + workshop = Fabricate(:workshop, + date_and_time: Time.zone.now - 30.minutes, + ends_at: Time.zone.now + 30.minutes) + post :create, params: { code: workshop.check_in_code, role: 'Student' } + invitation = WorkshopInvitation.find_by(workshop: workshop, member: member) + expect(invitation.source).to eq(InvitationConcerns::SOURCE_CHECK_IN) + expect(invitation.attending).to be true + expect(invitation.attended).to be true + expect(invitation.automated_rsvp).to be true + expect(response).to redirect_to(check_in_w_confirm_path(code: workshop.check_in_code)) + end + + it 'rejects workshop check-in when the member is on the waiting list' do + workshop = Fabricate(:workshop, + date_and_time: Time.zone.now - 30.minutes, + ends_at: Time.zone.now + 30.minutes) + invitation = Fabricate(:workshop_invitation, workshop: workshop, member: member, role: 'Student') + WaitingList.add(invitation) + post :create, params: { code: workshop.check_in_code, role: 'Student' } + expect(response).to redirect_to(check_in_w_path(code: workshop.check_in_code)) + expect(flash[:alert]).to include('waiting list') + end + end + end +end