diff --git a/core/src/main/java/google/registry/model/console/PasswordResetRequest.java b/core/src/main/java/google/registry/model/console/PasswordResetRequest.java
new file mode 100644
index 000000000..d85c7039b
--- /dev/null
+++ b/core/src/main/java/google/registry/model/console/PasswordResetRequest.java
@@ -0,0 +1,150 @@
+// Copyright 2025 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.model.console;
+
+import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
+
+import google.registry.model.Buildable;
+import google.registry.model.CreateAutoTimestamp;
+import google.registry.model.ImmutableObject;
+import google.registry.persistence.WithVKey;
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.AttributeOverrides;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Id;
+import java.util.Optional;
+import java.util.UUID;
+import org.joda.time.DateTime;
+
+/**
+ * Represents a password reset request of some type.
+ *
+ *
Password reset requests must be performed within an hour of the time that they were requested,
+ * as well as requiring that the requester and the fulfiller have the proper respective permissions.
+ */
+@Entity
+@WithVKey(String.class)
+public class PasswordResetRequest extends ImmutableObject implements Buildable {
+
+ public enum Type {
+ EPP,
+ REGISTRY_LOCK
+ }
+
+ @Id private String verificationCode;
+
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ Type type;
+
+ @AttributeOverrides({
+ @AttributeOverride(
+ name = "creationTime",
+ column = @Column(name = "requestTime", nullable = false))
+ })
+ CreateAutoTimestamp requestTime = CreateAutoTimestamp.create(null);
+
+ @Column(nullable = false)
+ String requester;
+
+ @Column DateTime fulfillmentTime;
+
+ @Column(nullable = false)
+ String destinationEmail;
+
+ @Column(nullable = false)
+ String registrarId;
+
+ public String getVerificationCode() {
+ return verificationCode;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public DateTime getRequestTime() {
+ return requestTime.getTimestamp();
+ }
+
+ public String getRequester() {
+ return requester;
+ }
+
+ public Optional getFulfillmentTime() {
+ return Optional.ofNullable(fulfillmentTime);
+ }
+
+ public String getDestinationEmail() {
+ return destinationEmail;
+ }
+
+ public String getRegistrarId() {
+ return registrarId;
+ }
+
+ @Override
+ public Builder asBuilder() {
+ return new Builder(clone(this));
+ }
+
+ /** Builder for constructing immutable {@link PasswordResetRequest} objects. */
+ public static class Builder extends Buildable.Builder {
+
+ public Builder() {}
+
+ private Builder(PasswordResetRequest instance) {
+ super(instance);
+ }
+
+ @Override
+ public PasswordResetRequest build() {
+ checkArgumentNotNull(getInstance().type, "Type must be specified");
+ checkArgumentNotNull(getInstance().requester, "Requester must be specified");
+ checkArgumentNotNull(getInstance().destinationEmail, "Destination email must be specified");
+ checkArgumentNotNull(getInstance().registrarId, "Registrar ID must be specified");
+ getInstance().verificationCode = UUID.randomUUID().toString();
+ return super.build();
+ }
+
+ public Builder setType(Type type) {
+ getInstance().type = type;
+ return this;
+ }
+
+ public Builder setRequester(String requester) {
+ getInstance().requester = requester;
+ return this;
+ }
+
+ public Builder setDestinationEmail(String destinationEmail) {
+ getInstance().destinationEmail = destinationEmail;
+ return this;
+ }
+
+ public Builder setRegistrarId(String registrarId) {
+ getInstance().registrarId = registrarId;
+ return this;
+ }
+
+ public Builder setFulfillmentTime(DateTime fulfillmentTime) {
+ getInstance().fulfillmentTime = fulfillmentTime;
+ return this;
+ }
+ }
+}
diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java
index ffe2abfc7..b3fbe1b59 100644
--- a/core/src/main/java/google/registry/model/registrar/Registrar.java
+++ b/core/src/main/java/google/registry/model/registrar/Registrar.java
@@ -600,13 +600,8 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
return getContacts().stream().filter(RegistrarPoc::getVisibleInDomainWhoisAsAbuse).findFirst();
}
- private ImmutableSet getContactPocs() {
- return tm().transact(
- () ->
- tm().query("FROM RegistrarPoc WHERE registrarId = :registrarId", RegistrarPoc.class)
- .setParameter("registrarId", registrarId)
- .getResultStream()
- .collect(toImmutableSet()));
+ private ImmutableList getContactPocs() {
+ return tm().transact(() -> RegistrarPoc.loadForRegistrar(registrarId));
}
@Override
diff --git a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java
index 49236a3a6..e942c82a3 100644
--- a/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java
+++ b/core/src/main/java/google/registry/model/registrar/RegistrarPoc.java
@@ -27,6 +27,7 @@ import static google.registry.util.PasswordUtils.hashPassword;
import static java.util.stream.Collectors.joining;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.gson.annotations.Expose;
@@ -36,6 +37,7 @@ import google.registry.model.JsonMapBuilder;
import google.registry.model.Jsonifiable;
import google.registry.model.UnsafeSerializable;
import google.registry.persistence.VKey;
+import google.registry.persistence.transaction.QueryComposer;
import google.registry.util.PasswordUtils;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -432,6 +434,12 @@ public class RegistrarPoc extends ImmutableObject implements Jsonifiable, Unsafe
}
}
+ public static ImmutableList loadForRegistrar(String registrarId) {
+ return tm().createQueryComposer(RegistrarPoc.class)
+ .where("registrarId", QueryComposer.Comparator.EQ, registrarId)
+ .list();
+ }
+
/** Class to represent the composite primary key for {@link RegistrarPoc} entity. */
@VisibleForTesting
public static class RegistrarPocId extends ImmutableObject implements Serializable {
diff --git a/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java b/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java
index 246392c88..1c9291b71 100644
--- a/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java
@@ -15,7 +15,6 @@
package google.registry.ui.server.console.settings;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.collect.Sets.difference;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
@@ -35,7 +34,6 @@ import google.registry.model.console.User;
import google.registry.model.registrar.Registrar;
import google.registry.model.registrar.RegistrarPoc;
import google.registry.model.registrar.RegistrarPoc.Type;
-import google.registry.persistence.transaction.QueryComposer.Comparator;
import google.registry.request.Action;
import google.registry.request.Action.GaeService;
import google.registry.request.Action.GkeService;
@@ -77,14 +75,7 @@ public class ContactAction extends ConsoleApiAction {
protected void getHandler(User user) {
checkPermission(user, registrarId, ConsolePermission.VIEW_REGISTRAR_DETAILS);
ImmutableList contacts =
- tm().transact(
- () ->
- tm()
- .createQueryComposer(RegistrarPoc.class)
- .where("registrarId", Comparator.EQ, registrarId)
- .stream()
- .collect(toImmutableList()));
-
+ tm().transact(() -> RegistrarPoc.loadForRegistrar(registrarId));
consoleApiParams.response().setStatus(SC_OK);
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(contacts));
}
diff --git a/core/src/main/resources/META-INF/persistence.xml b/core/src/main/resources/META-INF/persistence.xml
index 68bc8679d..8bf8f9ff7 100644
--- a/core/src/main/resources/META-INF/persistence.xml
+++ b/core/src/main/resources/META-INF/persistence.xml
@@ -47,13 +47,14 @@
google.registry.model.billing.BillingRecurrence
google.registry.model.common.Cursor
google.registry.model.common.DnsRefreshRequest
+ google.registry.model.common.FeatureFlag
google.registry.model.console.ConsoleUpdateHistory
+ google.registry.model.console.PasswordResetRequest
google.registry.model.console.User
google.registry.model.contact.ContactHistory
google.registry.model.contact.Contact
google.registry.model.domain.Domain
google.registry.model.domain.DomainHistory
- google.registry.model.common.FeatureFlag
google.registry.model.domain.GracePeriod
google.registry.model.domain.GracePeriod$GracePeriodHistory
google.registry.model.domain.secdns.DomainDsData
diff --git a/core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java b/core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java
new file mode 100644
index 000000000..d46ed1fc9
--- /dev/null
+++ b/core/src/test/java/google/registry/model/console/PasswordResetRequestTest.java
@@ -0,0 +1,65 @@
+// Copyright 2025 The Nomulus Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package google.registry.model.console;
+
+import static com.google.common.truth.Truth.assertThat;
+import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects;
+import static google.registry.testing.DatabaseHelper.persistResource;
+import static org.junit.Assert.assertThrows;
+
+import google.registry.model.EntityTestCase;
+import google.registry.persistence.VKey;
+import google.registry.testing.DatabaseHelper;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link PasswordResetRequest}. */
+public class PasswordResetRequestTest extends EntityTestCase {
+
+ PasswordResetRequestTest() {
+ super(JpaEntityCoverageCheck.ENABLED);
+ }
+
+ @Test
+ void testSuccess_persistence() {
+ PasswordResetRequest request =
+ new PasswordResetRequest.Builder()
+ .setRequester("requestor@email.tld")
+ .setDestinationEmail("destination@email.tld")
+ .setType(PasswordResetRequest.Type.EPP)
+ .setRegistrarId("TheRegistrar")
+ .build();
+ String verificationCode = request.getVerificationCode();
+ assertThat(verificationCode).isNotEmpty();
+ persistResource(request);
+ PasswordResetRequest fromDatabase =
+ DatabaseHelper.loadByKey(VKey.create(PasswordResetRequest.class, verificationCode));
+ assertAboutImmutableObjects().that(fromDatabase).isEqualExceptFields(request, "requestTime");
+ assertThat(fromDatabase.getRequestTime()).isEqualTo(fakeClock.nowUtc());
+ }
+
+ @Test
+ void testFailure_nullFields() {
+ PasswordResetRequest.Builder builder = new PasswordResetRequest.Builder();
+ assertThrows(IllegalArgumentException.class, builder::build);
+ builder.setType(PasswordResetRequest.Type.EPP);
+ assertThrows(IllegalArgumentException.class, builder::build);
+ builder.setRequester("foobar@email.tld");
+ assertThrows(IllegalArgumentException.class, builder::build);
+ builder.setDestinationEmail("email@email.tld");
+ assertThrows(IllegalArgumentException.class, builder::build);
+ builder.setRegistrarId("TheRegistrar");
+ builder.build();
+ }
+}
diff --git a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java
index 67eaa5b20..e73fe7074 100644
--- a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java
+++ b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java
@@ -25,6 +25,7 @@ import google.registry.model.common.CursorTest;
import google.registry.model.common.DnsRefreshRequestTest;
import google.registry.model.common.FeatureFlagTest;
import google.registry.model.console.ConsoleUpdateHistoryTest;
+import google.registry.model.console.PasswordResetRequestTest;
import google.registry.model.console.UserTest;
import google.registry.model.contact.ContactTest;
import google.registry.model.domain.DomainSqlTest;
@@ -104,6 +105,7 @@ import org.junit.runner.RunWith;
FeatureFlagTest.class,
HostHistoryTest.class,
LockTest.class,
+ PasswordResetRequestTest.class,
PollMessageTest.class,
PremiumListDaoTest.class,
RdeRevisionTest.class,
diff --git a/db/src/main/resources/sql/er_diagram/brief_er_diagram.html b/db/src/main/resources/sql/er_diagram/brief_er_diagram.html
index ff8167034..ccd569472 100644
--- a/db/src/main/resources/sql/er_diagram/brief_er_diagram.html
+++ b/db/src/main/resources/sql/er_diagram/brief_er_diagram.html
@@ -261,7 +261,7 @@ td.section {
| generated on |
- 2025-06-02 14:41:34 |
+ 2025-06-04 18:53:06 |
| last flyway file |
@@ -280,7 +280,7 @@ td.section {
generated by
SchemaCrawler 16.25.2
generated on
- 2025-06-02 14:41:34
+ 2025-06-04 18:53:06
@@ -2702,7 +2702,7 @@ td.section {
|
|
- default '2021-06-01 00:00:00+00'::timestamp with time zone |
+ default '2021-05-31 20:00:00-04'::timestamp with time zone |
|
diff --git a/db/src/main/resources/sql/er_diagram/full_er_diagram.html b/db/src/main/resources/sql/er_diagram/full_er_diagram.html
index d40163df1..dbc3e329a 100644
--- a/db/src/main/resources/sql/er_diagram/full_er_diagram.html
+++ b/db/src/main/resources/sql/er_diagram/full_er_diagram.html
@@ -261,7 +261,7 @@ td.section {
| generated on |
- 2025-06-02 14:41:30 |
+ 2025-06-04 18:53:03 |
| last flyway file |
@@ -280,7 +280,7 @@ td.section {
generated by
SchemaCrawler 16.25.2
generated on
- 2025-06-02 14:41:30
+ 2025-06-04 18:53:03
@@ -4806,7 +4806,7 @@ td.section {
|
|
- default '2021-06-01 00:00:00+00'::timestamp with time zone |
+ default '2021-05-31 20:00:00-04'::timestamp with time zone |
|
diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated
index 2465a2e86..078675fa8 100644
--- a/db/src/main/resources/sql/schema/db-schema.sql.generated
+++ b/db/src/main/resources/sql/schema/db-schema.sql.generated
@@ -562,6 +562,17 @@
primary key (package_promotion_id)
);
+ create table "PasswordResetRequest" (
+ verification_code text not null,
+ destination_email text not null,
+ fulfillment_time timestamp(6) with time zone,
+ registrar_id text not null,
+ request_time timestamp(6) with time zone not null,
+ requester text not null,
+ type text not null check (type in ('EPP','REGISTRY_LOCK')),
+ primary key (verification_code)
+ );
+
create table "PollMessage" (
type text not null,
poll_message_id bigint not null,